Skip to content

[ty] Discard the types of parameter defaults during cycle normalization#22845

Closed
AlexWaygood wants to merge 1 commit intomainfrom
alex/param-default-cycle-recovery
Closed

[ty] Discard the types of parameter defaults during cycle normalization#22845
AlexWaygood wants to merge 1 commit intomainfrom
alex/param-default-cycle-recovery

Conversation

@AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Jan 25, 2026

Summary

This PR tweaks ty's cycle recovery normalization for Callable types (and other types that have load-bearing Signatures) so that the exact type of a parameter default is discarded: a Callable with type (x: bool = True) -> int becomes a Callable with type (x: bool = ...) -> int.

Background

The motivation for this PR stems from the observation that applying this diff to the main branch:

diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 01bfd61d11..c396615242 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -11915,6 +11915,9 @@ impl<'db> UnionType<'db> {
         db: &'db dyn Db,
         mut f: impl FnMut(&Type<'db>) -> bool,
     ) -> Type<'db> {
+        if self.elements(db).iter().all(&mut f) {
+            return Type::Union(self);
+        }
         self.elements(db)
             .iter()
             .filter(|ty| f(ty))

leads to a behaviour change on this snippet:

from typing_extensions import ParamSpec, TypeVar, Callable

T = TypeVar("T")
P = ParamSpec("P")

def dispatch(
    _: Callable[P, T] | str
) -> Callable[[Callable[P, T]], Callable[P, T]]:
    raise NotImplementedError

def inject_client(fn: Callable[P, T]) -> Callable[P, T]:
    return fn

@inject_client
def aload(x: T, name: str, validate: bool = True) -> T:
    return x

@dispatch(aload)
def load(x: T, name: str) -> T:
    return x

load("", _sync=False)

On main, we emit 4 diagnostics on that snippet; but with the above patch applied, we emit 0. That's odd, because the patch should be a no-op in terms of behaviour!

Adding some debug prints appeared to indicate that we appear to enter a cycle when inferring a type for the aload function, and different iterations of the cycle yield different types: in one iteration we infer this type:

[T](x: T, name: str, validate: bool = ...) -> T

and in another iteration we infer this type:

[T](x: T, name: str, validate: bool = True) -> T

now: these are equivalent types! Normally, therefore, our UnionBuilder would never allow them to coexist in a UnionType together. Unfortunately, however, the union simplification we are able to do in cycle recovery is much more limited than the union simplification we are able to do in other scenarios: we cannot do any is_redundant_with calls during cycle recovery, because that could lead to further nested cycles! As such, we are unable to detect the mutual subtype relationship between the two types (because they are not identical types), and so the solved type of aload after cycle recovery is the union of the two:

([T](x: T, name: str, validate: bool = ...) -> T) | ([T](x: T, name: str, validate: bool = True) -> T)

On main, it appears that a UnionType::filter() call somewhere (I'm not sure exactly where) is then simplifying that union back into a Callable type, but with the UnionType::filter() patch above applied, this no longer happens, so it remains a UnionType. The result of that is that somewhere down the line, we appear to have a branch of code that treats a single Callable differently to a union of Callables (again, I haven't been able to track down precisely where this is, unfortunately), so that with the patch applied we infer the type of load as being (...) -> Unknown whereas on main we infer the type of load as being [T](x: T, name: str, validate: bool = ...) -> T. The inference on main seems clearly better to me!

It's desirable to be able to apply something similar to the patch above, because it leads to a significant speedup, especially on the incremental benchmark. Meanwhile, it's desirable to avoid the behaviour change, because the above snippet is a minimized repro of code in the prefect ecosystem project (specifically, src/integrations/prefect-azure/prefect_azure/experimental/bundles/execute.py). These weren't the flakes that we usually see on prefect: the reduction in diagnostics on this file persisted across several mypy_primer runs and could also be consistently reproduced locally.

More debugging details

The reason for the behaviour change appears to be that load is inferred as having type (...) -> Unknown with the above patch applied, whereas on main it's inferred as having type [T](x: T, name: str, validate: bool = ...) -> T.


To investigate further, I added some debug prints to the main branch:

diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 01bfd61d11..0229b7c4cb 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -11915,14 +11915,23 @@ impl<'db> UnionType<'db> {
         db: &'db dyn Db,
         mut f: impl FnMut(&Type<'db>) -> bool,
     ) -> Type<'db> {
-        self.elements(db)
+        let already_filtered = self.elements(db).iter().all(&mut f);
+        if already_filtered {
+            println!("Old = `{}`", Type::Union(self).display(db));
+        }
+        let new = self
+            .elements(db)
             .iter()
             .filter(|ty| f(ty))
             .fold(UnionBuilder::new(db), |builder, element| {
                 builder.add(*element)
             })
             .recursively_defined(self.recursively_defined(db))
-            .build()
+            .build();
+        if already_filtered {
+            println!("New = `{}`", new.display(db));
+        }
+        new
     }

And then ran the main branch on the above repro, with these results:

Old = `([T](x: T, name: str, validate: bool = ...) -> T) | ([T](x: T, name: str, validate: bool = True) -> T)`
New = `[T](x: T, name: str, validate: bool = ...) -> T`
Old = `((**P@dispatch) -> T@dispatch) | str`
New = `((**P@dispatch) -> T@dispatch) | str`
Old = `([T](x: T, name: str, validate: bool = ...) -> T) | ([T](x: T, name: str, validate: bool = True) -> T)`
New = `[T](x: T, name: str, validate: bool = ...) -> T`
Old = `((**P@dispatch) -> T@dispatch) | str`
New = `((**P@dispatch) -> T@dispatch) | str`

So we can see that on main, the type of aload is inferred to be a union with redundant elements in it (presumably due to cycle recovery somewhere), and then the UnionType::filter() call simplifies that union to a non-union type. Whereas on this branch, the UnionType::filter() call sees that all elements in the union satisfy the filter predicate, assumes that the union will already have been adequately normalized, and returns the union as-is.

The next question to investigate is: why are we treating a union of two identical callables differently to a single callable? They should be treated equivalently by ty...


Adding these debug prints indicates that after inferring the type of aload as (in different cycle iterations) ([T](x: T, name: str, validate: bool = ...) -> T) and ([T](x: T, name: str, validate: bool = True) -> T), at some point we fallback to (...) -> Unknown:

diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 01bfd61d11..bfd168bcd1 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -11915,6 +11915,9 @@ impl<'db> UnionType<'db> {
         db: &'db dyn Db,
         mut f: impl FnMut(&Type<'db>) -> bool,
     ) -> Type<'db> {
+        if self.elements(db).iter().all(|ty| f(ty)) {
+            return Type::Union(self);
+        }
         self.elements(db)
             .iter()
             .filter(|ty| f(ty))
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index b728f41cd0..d4163b00c2 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -2740,7 +2740,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
         self.undecorated_type = Some(inferred_ty);
 
         for (decorator_ty, decorator_node) in decorator_types_and_nodes.iter().rev() {
+            println!("decorator_ty = {}", decorator_ty.display(self.db()));
+            println!("Before decorator application = {}", inferred_ty.display(self.db()));
             inferred_ty = self.apply_decorator(*decorator_ty, inferred_ty, decorator_node);
+            println!("After decorator application = {}", inferred_ty.display(self.db()));
+            println!();
         }
 
         self.add_declaration_with_binding(
decorator_ty = def inject_client[**P, T](fn: (**P@inject_client) -> T) -> (**P@inject_client) -> T
Before decorator application = def aload[T](x: T, name: str, validate: bool = ...) -> T
After decorator application = [T](x: T, name: str, validate: bool = ...) -> T

decorator_ty = def inject_client[**P, T](fn: (**P@inject_client) -> T) -> (**P@inject_client) -> T
Before decorator application = def aload[T](x: T, name: str, validate: bool = True) -> T
After decorator application = [T](x: T, name: str, validate: bool = True) -> T

decorator_ty = def inject_client[**P, T](fn: (**P@inject_client) -> T) -> (**P@inject_client) -> T
Before decorator application = def aload[T](x: T, name: str, validate: bool = True) -> T
After decorator application = [T](x: T, name: str, validate: bool = True) -> T

decorator_ty = ((...) -> Unknown, /) -> (...) -> Unknown
Before decorator application = def load[T](x: T, name: str) -> T
After decorator application = (...) -> Unknown

Unanswered questions

There's still a lot I don't fully understand about what's going on here. I've tried to figure out some of them, but without much success:

  1. Why do we enter a cycle when inferring the type for aload?
  2. Why do we infer different types for the parameter defaults in aload on different iterations of the cycle?
  3. Where is the UnionType::filter() call that simplifies the union back to a single Callable type on main and "saves" us?
  4. Where in our code do we treat a union of callables differently to a single callable, such that it leads to much worse inference for the type of load if aload is inferred as having a union type at some point?

Proposed change in this PR

Despite the unanswered questions above, I'm proposing a change to our cycle normalization for Parameters. I don't think the questions above need to be answered for this PR to be a demonstrably worthwhile change.

On this PR branch, we discard the exact type of a parameter default (replacing it with Never, simply because it's an extremely simple type) when cycle-normalizing Parameters. We now retain only the knowledge that the parameter has a default value -- which is all that is relevant for type-checking. The only situation where we ever use the type of the default value is when we're displaying a signature to the user in a diagnostic or on-hover. This is exactly the same as what we already do for "regular" (non-cycle) normalization:

// Ensure that parameter names are stripped from positional-only, variadic and keyword-variadic parameters.
// Ensure that we only record whether a parameter *has* a default
// (strip the precise *type* of the default from the parameter, replacing it with `Never`).
let kind = match kind {
ParameterKind::PositionalOnly {
name: _,
default_type,
} => ParameterKind::PositionalOnly {
name: None,
default_type: default_type.map(|_| Type::Never),
},
ParameterKind::PositionalOrKeyword { name, default_type } => {
ParameterKind::PositionalOrKeyword {
name: name.clone(),
default_type: default_type.map(|_| Type::Never),
}
}
ParameterKind::KeywordOnly { name, default_type } => ParameterKind::KeywordOnly {
name: name.clone(),
default_type: default_type.map(|_| Type::Never),
},
ParameterKind::Variadic { name: _ } => ParameterKind::Variadic {
name: Name::new_static("args"),
},
ParameterKind::KeywordVariadic { name: _ } => ParameterKind::KeywordVariadic {
name: Name::new_static("kwargs"),
},
};

The change made here means that we are able to identify even inside cycle recovery that [T](x: T, name: str, validate: bool = ...) -> T is equivalent to [T](x: T, name: str, validate: bool = True) -> T -- because they both cycle-normalize to the same type: [T](x: T, name: str, validate: bool = ...) -> T. As such, applying the above patch to this PR branch does not cause ty to exhibit the same behaviour change as applying the same patch to main does.

This PR therefore unblocks the performance optimisation proposed in #22352. I also think it makes sense on its own merits:

  • This means we have to do less work during cycle normalization for Parameters, which should theoretically speed us up when analyzing projects with many nested cycles.
  • There's a chance this could improve the likelihood that cycles involving Callable types converge successfully.
  • Cycle normalization should (hopefully) happen rarely, so this shouldn't result in many user-visible changes. We should still show the parameter's real default value in most cases. The mypy_primer report appears to confirm this.

As well as the above motivations, I'm making this a standalone change to demonstrate that it has no impact on the ecosystem, our test suite, or our benchmarks. (Codspeed reports speedups of 1-2% on some benchmarks, but that's small enough that it could well just be noise.)

Test Plan

  • Existing tests all pass
  • The primer report is clean

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Jan 25, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 25, 2026

Typing conformance results

No changes detected ✅

@AlexWaygood AlexWaygood force-pushed the alex/param-default-cycle-recovery branch from ede691c to 72b678e Compare January 25, 2026 11:59
@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 25, 2026

mypy_primer results

Changes were detected when running on open source projects
pydantic (https://github.com/pydantic/pydantic)
- pydantic/main.py:271:30: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:271:30: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:282:39: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:282:39: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:292:30: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:292:30: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:441:10: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:441:10: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:562:10: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:562:10: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:982:34: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:982:34: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:1136:31: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:1136:31: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:1147:35: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:1147:35: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression
- pydantic/main.py:1290:29: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = None, by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False) -> dict[str, Any]` in type expression
+ pydantic/main.py:1290:29: error[invalid-type-form] Invalid subscript of object of type `def dict(self, *, include: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., exclude: set[int] | set[str] | Mapping[int, Divergent] | Mapping[str, Divergent] | None = ..., by_alias: bool = ..., exclude_unset: bool = ..., exclude_defaults: bool = ..., exclude_none: bool = ...) -> dict[str, Any]` in type expression

pyodide (https://github.com/pyodide/pyodide)
- src/py/pyodide/webloop.py:980:5: error[invalid-assignment] Object of type `_Wrapped[(main: Coroutine[Any, Any, _T@run], *, debug: bool | None = None), _T@run, (main, *, debug=None, loop_factory=None), Unknown]` is not assignable to attribute `run` of type `def run[_T](main: Coroutine[Any, Any, _T], *, debug: bool | None = None) -> _T`
+ src/py/pyodide/webloop.py:980:5: error[invalid-assignment] Object of type `_Wrapped[(main: Coroutine[Any, Any, _T@run], *, debug: bool | None = ...), _T@run, (main, *, debug=..., loop_factory=...), Unknown]` is not assignable to attribute `run` of type `def run[_T](main: Coroutine[Any, Any, _T], *, debug: bool | None = None) -> _T`

setuptools (https://github.com/pypa/setuptools)
- setuptools/_vendor/autocommand/autoparse.py:306:5: error[unresolved-attribute] Unresolved attribute `func` on type `_Wrapped[(...), Unknown, (argv=None), Unknown]`
+ setuptools/_vendor/autocommand/autoparse.py:306:5: error[unresolved-attribute] Unresolved attribute `func` on type `_Wrapped[(...), Unknown, (argv=...), Unknown]`
- setuptools/_vendor/autocommand/autoparse.py:307:5: error[unresolved-attribute] Unresolved attribute `parser` on type `_Wrapped[(...), Unknown, (argv=None), Unknown]`
+ setuptools/_vendor/autocommand/autoparse.py:307:5: error[unresolved-attribute] Unresolved attribute `parser` on type `_Wrapped[(...), Unknown, (argv=...), Unknown]`
- setuptools/tests/test_wheel.py:677:13: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `str | dict[Unknown | str, Unknown] | dict[str, list[Unknown | str]] | Unknown`
- Found 1133 diagnostics
+ Found 1132 diagnostics

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Series[Any, Any]] | TypeBlocks | ... omitted 6 union elements, object_]`
- static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Bus[Any] | IndexHierarchy | TypeBlocks | ... omitted 7 union elements, object_ | Self@iloc]`
+ static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Self@iloc | Bus[Any], object_ | Self@iloc]`
- static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | IndexHierarchy | TypeBlocks | ... omitted 7 union elements, TVDtype@Series]`
- static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | IndexHierarchy | TypeBlocks | ... omitted 8 union elements, TVDtype@SeriesHE]`
- static_frame/core/yarn.py:418:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Yarn[Any], object_]`, found `InterGetItemILocReduces[Yarn[Any] | IndexHierarchy | TypeBlocks | ... omitted 7 union elements, object_]`
- Found 1823 diagnostics
+ Found 1819 diagnostics

rotki (https://github.com/rotki/rotki)
+ rotkehlchen/chain/decoding/tools.py:96:44: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- rotkehlchen/chain/decoding/tools.py:97:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress`, found `A@BaseDecoderTools`
+ rotkehlchen/chain/decoding/tools.py:99:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `Sequence[A@BaseDecoderTools]`, found `Unknown | tuple[BTCAddress, ...] | tuple[ChecksumAddress, ...] | tuple[SubstrateAddress, ...] | tuple[SolanaAddress, ...]`
- rotkehlchen/chain/decoding/tools.py:98:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress | None`, found `A@BaseDecoderTools | None`
+ rotkehlchen/chain/decoding/tools.py:100:62: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 2050 diagnostics
+ Found 2051 diagnostics

core (https://github.com/home-assistant/core)
- homeassistant/core.py:561:31: error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `(...) -> None`, found `Overload[[_R](hassjob: HassJob[(...), Coroutine[Any, Any, _R]], *args: Any, *, background: bool = False) -> Future[_R] | None, [_R](hassjob: HassJob[(...), Coroutine[Any, Any, _R] | _R], *args: Any, *, background: bool = False) -> Future[_R] | None]`
+ homeassistant/core.py:561:31: error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `(...) -> None`, found `Overload[[_R](hassjob: HassJob[(...), Coroutine[Any, Any, _R]], *args: Any, *, background: bool = ...) -> Future[_R] | None, [_R](hassjob: HassJob[(...), Coroutine[Any, Any, _R] | _R], *args: Any, *, background: bool = ...) -> Future[_R] | None]`
- homeassistant/util/variance.py:47:12: error[invalid-return-type] Return type does not match returned value: expected `(**_P@ignore_variance) -> _R@ignore_variance`, found `_Wrapped[_P@ignore_variance, int | _R@ignore_variance | float | datetime, _P@ignore_variance, _R@ignore_variance | int | float | datetime]`
- Found 14481 diagnostics
+ Found 14480 diagnostics

scipy (https://github.com/scipy/scipy)
- scipy/stats/_axis_nan_policy.py:721:9: error[unresolved-attribute] Unresolved attribute `__signature__` on type `_Wrapped[(...), Unknown, (*args, *, _no_deco=False, **kwds), Unknown]`
+ scipy/stats/_axis_nan_policy.py:721:9: error[unresolved-attribute] Unresolved attribute `__signature__` on type `_Wrapped[(...), Unknown, (*args, *, _no_deco=..., **kwds), Unknown]`

No memory usage changes detected ✅

@carljm
Copy link
Contributor

carljm commented Feb 2, 2026

With #23014 we no longer enter into spurious cycles when defining a decorated function with parameters with default values, which fixes the motivating case here -- the results on that snippet are now the same, with or without the patch to UnionType::filter.

I'd suggest rebasing the next PR in this stack on top of #23014 and see if any behavior changes are still observed. If not, I'd suggest we don't make this change for now.

@sharkdp sharkdp removed their request for review February 3, 2026 13:19
@AlexWaygood AlexWaygood marked this pull request as draft February 9, 2026 21:19
@AlexWaygood AlexWaygood closed this Feb 9, 2026
@AlexWaygood AlexWaygood deleted the alex/param-default-cycle-recovery branch February 9, 2026 22:12
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