Skip to content

[ty] Fix ParamSpec defaults and alias variance#24479

Merged
charliermarsh merged 7 commits into
mainfrom
charlie/variance
Apr 29, 2026
Merged

[ty] Fix ParamSpec defaults and alias variance#24479
charliermarsh merged 7 commits into
mainfrom
charlie/variance

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

@charliermarsh charliermarsh commented Apr 8, 2026

Summary

This PR fixes several ParamSpec variance and gradual-specialization edge cases that fell out of #24319.

We also now treat typing_extensions.ParamSpec defaults like typing.ParamSpec defaults, which I think was an oversight.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Apr 8, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

Typing conformance results improved 🎉

The percentage of diagnostics emitted that were expected errors increased from 88.70% to 88.79%. The percentage of expected errors that received a diagnostic held steady at 84.63%. The number of fully passing files held steady at 84/134.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 903 903 +0
False Positives 115 114 -1 ⏬ (✅)
False Negatives 164 164 +0
Total Diagnostics 1068 1067 -1
Precision 88.70% 88.79% +0.09% ⏫ (✅)
Recall 84.63% 84.63% +0.00%
Passing Files 84/134 84/134 +0

Test file breakdown

1 file altered
File True Positives False Positives False Negatives Status
generics_defaults.py 5 7 (-1) ✅ 1 📈 Improving
Total (all files) 903 114 (-1) ✅ 164 84/134

False positives removed (1)

1 diagnostic
Test case Diff

generics_defaults.py:122

-error[type-assertion-failure] Type `(**DefaultP@Class_ParamSpec) -> None` does not match asserted type `(str, int, /) -> None`

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

Memory usage report

Summary

Project Old New Diff Outcome
flake8 47.73MB 47.73MB -
sphinx 259.64MB 259.64MB -
trio 116.92MB 116.92MB -
prefect 702.95MB 692.15MB -1.54% (10.80MB) ⬇️

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_expression_types_impl 63.14MB 60.04MB -4.91% (3.10MB) ⬇️
infer_expression_type_impl 13.51MB 11.36MB -15.93% (2.15MB) ⬇️
infer_definition_types 89.39MB 87.45MB -2.16% (1.93MB) ⬇️
StaticClassLiteral<'db>::implicit_attribute_inner_ 10.07MB 8.90MB -11.57% (1.16MB) ⬇️
all_narrowing_constraints_for_expression 7.36MB 6.67MB -9.39% (707.97kB) ⬇️
GenericAlias<'db>::variance_of_ 585.49kB 58.04kB -90.09% (527.45kB) ⬇️
all_negative_narrowing_constraints_for_expression 2.67MB 2.21MB -17.50% (479.07kB) ⬇️
FunctionType<'db>::signature_ 4.32MB 4.08MB -5.60% (247.85kB) ⬇️
Type<'db>::member_lookup_with_policy_ 17.30MB 17.18MB -0.70% (124.00kB) ⬇️
infer_deferred_types 11.12MB 11.01MB -0.96% (109.84kB) ⬇️
is_redundant_with_impl 2.00MB 1.93MB -3.80% (77.99kB) ⬇️
Type<'db>::apply_specialization_ 3.74MB 3.67MB -1.78% (68.06kB) ⬇️
infer_scope_types_impl 55.32MB 55.28MB -0.09% (49.23kB) ⬇️
StaticClassLiteral<'db>::variance_of_ 209.58kB 177.81kB -15.16% (31.77kB) ⬇️
FunctionType<'db>::last_definition_signature_ 865.03kB 833.64kB -3.63% (31.38kB) ⬇️
... 26 more

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-assignment 0 0 10
unresolved-attribute 0 0 3
invalid-argument-type 2 0 0
missing-argument 1 0 0
Total 3 0 13

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Raw diff (16 changes)
core (https://github.com/home-assistant/core)
+ homeassistant/components/rflink/__init__.py:270:56 error[invalid-argument-type] Argument to function `async_call_later` is incorrect: Expected `HassJob[(datetime, /), Coroutine[Any, Any, None] | None] | ((datetime, /) -> Coroutine[Any, Any, None] | None)`, found `HassJob[(_: Exception | None = None), None]`

discord.py (https://github.com/Rapptz/discord.py)
- discord/ext/commands/core.py:1942:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]] & ~<Protocol with members '__commands_checks__'>`
+ discord/ext/commands/core.py:1942:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]] & ~<Protocol with members '__commands_checks__'>`
- discord/ext/commands/core.py:1944:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]` has no attribute `__commands_checks__`
+ discord/ext/commands/core.py:1944:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]` has no attribute `__commands_checks__`
- discord/ext/commands/core.py:2365:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]] & ~<Protocol with members '__commands_checks__'>`
+ discord/ext/commands/core.py:2365:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]] & ~<Protocol with members '__commands_checks__'>`
- discord/ext/commands/core.py:2367:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]` has no attribute `__commands_checks__`
+ discord/ext/commands/core.py:2367:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]` has no attribute `__commands_checks__`
- discord/ext/commands/core.py:2368:13 error[invalid-assignment] Object of type `Literal[True]` is not assignable to attribute `__discord_app_commands_guild_only__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2368:13 error[invalid-assignment] Object of type `Literal[True]` is not assignable to attribute `__discord_app_commands_guild_only__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2440:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]] & ~<Protocol with members '__commands_checks__'>`
+ discord/ext/commands/core.py:2440:17 error[invalid-assignment] Object of type `list[Unknown]` is not assignable to attribute `__commands_checks__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]] & ~<Protocol with members '__commands_checks__'>`
- discord/ext/commands/core.py:2442:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]` has no attribute `__commands_checks__`
+ discord/ext/commands/core.py:2442:13 error[unresolved-attribute] Object of type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]` has no attribute `__commands_checks__`
- discord/ext/commands/core.py:2443:13 error[invalid-assignment] Object of type `Literal[True]` is not assignable to attribute `__discord_app_commands_is_nsfw__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2443:13 error[invalid-assignment] Object of type `Literal[True]` is not assignable to attribute `__discord_app_commands_is_nsfw__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2499:13 error[invalid-assignment] Object of type `CooldownMapping[Context[Any]]` is not assignable to attribute `__commands_cooldown__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2499:13 error[invalid-assignment] Object of type `CooldownMapping[Context[Any]]` is not assignable to attribute `__commands_cooldown__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2547:13 error[invalid-assignment] Object of type `DynamicCooldownMapping[Context[Any]]` is not assignable to attribute `__commands_cooldown__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2547:13 error[invalid-assignment] Object of type `DynamicCooldownMapping[Context[Any]]` is not assignable to attribute `__commands_cooldown__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2582:13 error[invalid-assignment] Object of type `MaxConcurrency` is not assignable to attribute `__commands_max_concurrency__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2582:13 error[invalid-assignment] Object of type `MaxConcurrency` is not assignable to attribute `__commands_max_concurrency__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2634:13 error[invalid-assignment] Object of type `((CogT@before_invoke, ContextT@before_invoke, /) -> Coroutine[Any, Any, Any]) | ((ContextT@before_invoke, /) -> Coroutine[Any, Any, Any])` is not assignable to attribute `__before_invoke__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2634:13 error[invalid-assignment] Object of type `((CogT@before_invoke, ContextT@before_invoke, /) -> Coroutine[Any, Any, Any]) | ((ContextT@before_invoke, /) -> Coroutine[Any, Any, Any])` is not assignable to attribute `__before_invoke__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`
- discord/ext/commands/core.py:2657:13 error[invalid-assignment] Object of type `((CogT@after_invoke, ContextT@after_invoke, /) -> Coroutine[Any, Any, Any]) | ((ContextT@after_invoke, /) -> Coroutine[Any, Any, Any])` is not assignable to attribute `__after_invoke__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, Top[(...)], Unknown]]`
+ discord/ext/commands/core.py:2657:13 error[invalid-assignment] Object of type `((CogT@after_invoke, ContextT@after_invoke, /) -> Coroutine[Any, Any, Any]) | ((ContextT@after_invoke, /) -> Coroutine[Any, Any, Any])` is not assignable to attribute `__after_invoke__` on type `((...) -> Coroutine[Any, Any, Any]) & ~Top[Command[Unknown, (...), Unknown]]`

starlette (https://github.com/encode/starlette)
+ tests/middleware/test_base.py:247:33 error[missing-argument] No argument provided for required parameter 1 of `Middleware.__init__`

streamlit (https://github.com/streamlit/streamlit)
+ lib/streamlit/runtime/caching/cache_utils.py:265:57 error[invalid-argument-type] Argument to `BoundCachedFunc.__init__` is incorrect: Expected `CachedFunc[_PWrapper@update_wrapper, Unknown]`, found `Self@__get__`

Full report with detailed diff (timing results)

@charliermarsh charliermarsh marked this pull request as ready for review April 8, 2026 01:57
@charliermarsh charliermarsh marked this pull request as draft April 8, 2026 02:10
@charliermarsh charliermarsh marked this pull request as ready for review April 28, 2026 13:52
@charliermarsh charliermarsh force-pushed the charlie/variance branch 2 times, most recently from 86d80d7 to f994992 Compare April 28, 2026 15:22
@charliermarsh charliermarsh marked this pull request as draft April 28, 2026 15:46
&& Self::is_gradual_paramspec_value(db, other) =>
{
self.always()
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see the ecosystem diagnostics addressed by this change here: #24903

@charliermarsh charliermarsh force-pushed the charlie/variance branch 2 times, most recently from 630cd23 to 8f9fbfd Compare April 28, 2026 17:51
} else {
variance
}
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see the ecosystem diagnostics fixed by this change here: #24910

&& Self::is_top_paramspec_value(db, other) =>
{
self.always()
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see the ecosystem diagnostics addressed by this change here: #24911

@charliermarsh charliermarsh marked this pull request as ready for review April 28, 2026 18:19
Copy link
Copy Markdown
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, thank you!!

BTW there's a typing-spec PR in the works, with solid typing council support, that will fully support variance for ParamSpec, including enabling the covariant=True and contravariant=True arguments to legacy typing.ParamSpec. But that should probably be a separate PR, once the typing spec PR lands.

```

`...` has the same gradual behavior when used as a `ParamSpec` argument in a generic class,
regardless of the inferred variance of the `ParamSpec`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't use inferred variance (it uses a legacy ParamSpec), so it's odd to specifically reference inferred variance here.

Suggested change
regardless of the inferred variance of the `ParamSpec`.
regardless of the variance of the `ParamSpec`.

```py
from typing import Callable, Generic, ParamSpec

P = ParamSpec("P")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity maybe

Suggested change
P = ParamSpec("P")
# legacy ParamSpec with no variance specified is invariant
P = ParamSpec("P")

Comment on lines +491 to +493
def _(concrete: Command[[str]], gradual: Command[...]) -> None:
a: Command[...] = concrete
b: Command[[str]] = gradual
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's useful to also add assertions demonstrating that we are definitely dealing with an invariant paramspec here -- that makes the two gradual tests more meaningful.

Suggested change
def _(concrete: Command[[str]], gradual: Command[...]) -> None:
a: Command[...] = concrete
b: Command[[str]] = gradual
# confirm that Command is invariant in P
def _(of_int: Command[int], of_bool: Command[bool]) -> None:
a: Command[int] = of_bool # error: [invalid-assignment]
b: Command[bool] = of_int # error: [invalid-assignment]
# but gradual signature is still assignable in both directions
def _(concrete: Command[[str]], gradual: Command[...]) -> None:
a: Command[...] = concrete
b: Command[[str]] = gradual

```

The gradual `...` form of a `ParamSpec` argument is assignable to and from a concrete `ParamSpec`
value when it appears in a generic class. This is assignability consistency, not subtyping.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
value when it appears in a generic class. This is assignability consistency, not subtyping.
value when it appears in a generic class. This is assignability, not subtyping.

Comment on lines +1538 to +1540
static_assert(is_assignable_to(TypeOf[named_job], Job[[int]]))
static_assert(is_assignable_to(TypeOf[defaulted_job], Job[[int]]))
static_assert(not is_assignable_to(TypeOf[wrong_job], Job[[int]]))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test feels out of place in this file, because it's less of a "type properties" test and more a "real use case" test, and it has to kind of stretch to use is_assignable_to. It would be significantly simpler and shorter just to have a function that takes an argument of type Job[[int]] and try calling that function with all three of named_job, defaulted_job and wrong_job. I would make that change, and move this test over to paramspec.md.

reveal_type(TypeVarAndParamSpec[int, Any]().attr) # revealed: (...) -> int
```

`...` has the same gradual behavior when used as a `ParamSpec` argument in a generic class,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically if we are asserting "core" behavior of a generic, we try to add the same tests in both legacy/paramspec.md and pep695/paramspec.md (using the two different syntaxes) to make sure we keep the behavior in sync.

from ty_extensions import static_assert, is_assignable_to, is_subtype_of
from typing import Callable, Generic, ParamSpec

P = ParamSpec("P")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like the same test as in legacy/paramspec.md?

if known_class == Some(KnownClass::ParamSpec) {
if matches!(
known_class,
Some(KnownClass::ParamSpec | KnownClass::ExtensionsParamSpec)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test to lock this in?

Comment on lines +1913 to +1916
let Type::Callable(callable) = ty else {
return false;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we could simplify by having this method take a CallableType (or be a method on CallableType, for that matter), and avoid needing this unwrapping. The relation-checking arm above that calls this method could just as easily match on Type::Callable(other) instead of plain other.

Comment on lines +1926 to +1928
let Type::Callable(callable) = ty else {
return false;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@charliermarsh charliermarsh merged commit 1b931ba into main Apr 29, 2026
57 checks passed
@charliermarsh charliermarsh deleted the charlie/variance branch April 29, 2026 13:04
@charliermarsh
Copy link
Copy Markdown
Member Author

Cool I can own following up on that spec/conformance PR.

charliermarsh added a commit that referenced this pull request Apr 29, 2026
## Summary

This PR adds `covariant`, `contravariant`, and `infer_variance` support
to `ParamSpec`.

See: python/typing#2215.

See:
#24479 (review).
thejchap pushed a commit to thejchap/ruff that referenced this pull request May 23, 2026
## Summary

This PR fixes several ParamSpec variance and gradual-specialization edge
cases that fell out of astral-sh#24319.

We also now treat `typing_extensions.ParamSpec` defaults like
`typing.ParamSpec` defaults, which I think was an oversight.
thejchap pushed a commit to thejchap/ruff that referenced this pull request May 23, 2026
## Summary

This PR adds `covariant`, `contravariant`, and `infer_variance` support
to `ParamSpec`.

See: python/typing#2215.

See:
astral-sh#24479 (review).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants