From 09498fbec520b593d220b751864f84061dd58d44 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 20 Aug 2025 15:36:03 +0200 Subject: [PATCH 1/7] Merge all or-ed constraints if they have the same structure --- mypy/constraints.py | 3 ++- test-data/unit/check-inference-context.test | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 6416791fa74a8..ea375696913ba 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -541,13 +541,14 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l # TODO: More generally, if a given (variable, direction) pair appears in # every option, combine the bounds with meet/join always, not just for Any. trivial_options = select_trivial(valid_options) - if trivial_options and len(trivial_options) < len(valid_options): + if 0 < len(trivial_options) < len(valid_options): merged_options = [] for option in valid_options: if option in trivial_options: continue merged_options.append([merge_with_any(c) for c in option]) return any_constraints(list(merged_options), eager=eager) + return sum(valid_options, []) # If normal logic didn't work, try excluding trivially unsatisfiable constraint (due to # upper bounds) from each option, and comparing them again. diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index 5a674cca09da3..59f6c62202cb2 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1530,3 +1530,15 @@ def check3(use: bool, val: str) -> "str | Literal[False]": def check4(use: bool, val: str) -> "str | bool": return use and identity(val) [builtins fixtures/tuple.pyi] + +[case testDictOrLiteralInContext] +from typing import Union, Optional, Any + +P = dict[str, Union[Optional[str], dict[str, Optional[str]]]] + +def f(x: P) -> None: + pass + +def g(x: Union[dict[str, Any], None], s: Union[str, None]) -> None: + f(x or {'x': s}) +[builtins fixtures/dict.pyi] From 948347c9b8935d6bc61eaad52523ae72d24c4d56 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 20 Aug 2025 15:36:26 +0200 Subject: [PATCH 2/7] Restore recursive types treatment --- mypy/constraints.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index ea375696913ba..6fd912f1dab36 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -411,18 +411,15 @@ def _infer_constraints( # When the template is a union, we are okay with leaving some # type variables indeterminate. This helps with some special # cases, though this isn't very principled. - result = any_constraints( + if has_recursive_types(template) and not has_recursive_types(actual): + return handle_recursive_union(template, actual, direction) + return any_constraints( [ infer_constraints_if_possible(t_item, actual, direction) for t_item in template.items ], eager=isinstance(actual, AnyType), ) - if result: - return result - elif has_recursive_types(template) and not has_recursive_types(actual): - return handle_recursive_union(template, actual, direction) - return [] # Remaining cases are handled by ConstraintBuilderVisitor. return template.accept(ConstraintBuilderVisitor(actual, direction, skip_neg_op)) From aff5fc74070948de1b60124ad0bcee131ad1dbf2 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 20 Aug 2025 15:40:03 +0200 Subject: [PATCH 3/7] Deduplicate immediately to avoid self-joins --- mypy/constraints.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 6fd912f1dab36..621b1da32eba6 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -535,8 +535,6 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l if all(is_similar_constraints(valid_options[0], c) for c in valid_options[1:]): # All options have same structure. In this case we can merge-in trivial # options (i.e. those that only have Any) and try again. - # TODO: More generally, if a given (variable, direction) pair appears in - # every option, combine the bounds with meet/join always, not just for Any. trivial_options = select_trivial(valid_options) if 0 < len(trivial_options) < len(valid_options): merged_options = [] @@ -545,7 +543,10 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l continue merged_options.append([merge_with_any(c) for c in option]) return any_constraints(list(merged_options), eager=eager) - return sum(valid_options, []) + # Solver will apply meets and joins as necessary, return everything we know. + # Just deduplicate to reduce the amount of work. + all_combined = sum(valid_options, []) + return list(dict.fromkeys(all_combined)) # If normal logic didn't work, try excluding trivially unsatisfiable constraint (due to # upper bounds) from each option, and comparing them again. From afbb9e8db28d4193469fd9450502ec5e5b44fd28 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Fri, 22 Aug 2025 12:46:07 +0200 Subject: [PATCH 4/7] Only combine after removing trivially unsatisfiable constraints --- mypy/constraints.py | 14 +++++++++----- test-data/unit/check-inference-context.test | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 621b1da32eba6..78a03eece528e 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -532,7 +532,8 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l # Multiple sets of constraints that are all the same. Just pick any one of them. return valid_options[0] - if all(is_similar_constraints(valid_options[0], c) for c in valid_options[1:]): + all_similar = all(is_similar_constraints(valid_options[0], c) for c in valid_options[1:]) + if all_similar: # All options have same structure. In this case we can merge-in trivial # options (i.e. those that only have Any) and try again. trivial_options = select_trivial(valid_options) @@ -543,10 +544,6 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l continue merged_options.append([merge_with_any(c) for c in option]) return any_constraints(list(merged_options), eager=eager) - # Solver will apply meets and joins as necessary, return everything we know. - # Just deduplicate to reduce the amount of work. - all_combined = sum(valid_options, []) - return list(dict.fromkeys(all_combined)) # If normal logic didn't work, try excluding trivially unsatisfiable constraint (due to # upper bounds) from each option, and comparing them again. @@ -562,6 +559,13 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l if filtered_options != options: return any_constraints(filtered_options, eager=eager) + if all_similar: + # Now we know all constraints might be satisfiable and have similar structure. + # Solver will apply meets and joins as necessary, return everything we know. + # Just deduplicate to reduce the amount of work. + all_combined = sum(valid_options, []) + return list(dict.fromkeys(all_combined)) + # Otherwise, there are either no valid options or multiple, inconsistent valid # options. Give up and deduce nothing. return [] diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test index 59f6c62202cb2..d31b482db8e35 100644 --- a/test-data/unit/check-inference-context.test +++ b/test-data/unit/check-inference-context.test @@ -1542,3 +1542,21 @@ def f(x: P) -> None: def g(x: Union[dict[str, Any], None], s: Union[str, None]) -> None: f(x or {'x': s}) [builtins fixtures/dict.pyi] + +[case testInferConstrainedTypeVarInUnion] +from typing import Generic, TypeVar, Union + +_S_co = TypeVar("_S_co", str, int, covariant=True) +_S = TypeVar("_S", str, int) + +class HasFoo(Generic[_S_co]): + def foo(self) -> _S_co: ... + +def walk(path: Union[_S, HasFoo[_S]]) -> None: + ... + +class Path(HasFoo[str]): + def foo(self) -> str: ... + +walk(Path()) +[builtins fixtures/tuple.pyi] From b5b7981e65995ab4488a4f707c520b7b9be43b07 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Fri, 22 Aug 2025 13:38:23 +0200 Subject: [PATCH 5/7] And only if we insist on infering *something* --- mypy/constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 78a03eece528e..44cf6515310fe 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -559,7 +559,7 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l if filtered_options != options: return any_constraints(filtered_options, eager=eager) - if all_similar: + if eager and all_similar: # Now we know all constraints might be satisfiable and have similar structure. # Solver will apply meets and joins as necessary, return everything we know. # Just deduplicate to reduce the amount of work. From 3cafa45ff7bd1143276f27731039a172017dbaed Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 27 Aug 2025 16:43:39 +0200 Subject: [PATCH 6/7] Prevent discarding ErasedType - it's better to infer nothing than to infer a union subset --- mypy/constraints.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 44cf6515310fe..62e5c0189e7f9 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -559,10 +559,16 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l if filtered_options != options: return any_constraints(filtered_options, eager=eager) - if eager and all_similar: + if ( + eager + and all_similar + and not any(isinstance(c.target, ErasedType) for group in valid_options for c in group) + ): # Now we know all constraints might be satisfiable and have similar structure. # Solver will apply meets and joins as necessary, return everything we know. # Just deduplicate to reduce the amount of work. + # If any targets are erased, fall back to empty, otherwise they will be discarded + # by solver, causing false early matches. all_combined = sum(valid_options, []) return list(dict.fromkeys(all_combined)) From 12755b690e6a9d30d07c5a79da59e5482219852b Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 27 Aug 2025 23:46:09 +0200 Subject: [PATCH 7/7] Force merge Any into constraints --- mypy/constraints.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 62e5c0189e7f9..bf69da468a69c 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -565,12 +565,24 @@ def any_constraints(options: list[list[Constraint] | None], *, eager: bool) -> l and not any(isinstance(c.target, ErasedType) for group in valid_options for c in group) ): # Now we know all constraints might be satisfiable and have similar structure. - # Solver will apply meets and joins as necessary, return everything we know. - # Just deduplicate to reduce the amount of work. + # Solver will apply meets and joins as necessary, but Any should be forced into + # union to survive during meet. # If any targets are erased, fall back to empty, otherwise they will be discarded # by solver, causing false early matches. - all_combined = sum(valid_options, []) - return list(dict.fromkeys(all_combined)) + cmap: dict[TypeVarId, list[Constraint]] = {} + for option in valid_options: + for c in option: + cmap.setdefault(c.type_var, []).append(c) + out: list[Constraint] = [] + for group in cmap.values(): + if any(isinstance(get_proper_type(c.target), AnyType) for c in group): + group = [ + merge_with_any(c) + for c in group + if not isinstance(get_proper_type(c.target), AnyType) + ] + out.extend(dict.fromkeys(group)) + return out # Otherwise, there are either no valid options or multiple, inconsistent valid # options. Give up and deduce nothing.