Skip to content

Conversation

@randolf-scholz
Copy link
Contributor

@randolf-scholz randolf-scholz commented Dec 15, 2025

Fixes: #20424

Following my comments in #20416, this PR introduces a new helper function as_type that can be used to determine whether a type can be matched to a certain protocol or not.

As an example application, I fix a bug in is_valid_keyword_var_arg that stems from checking

kwargs <: SupportsKeyAndGetItem[str, Any]

This check is too eager, because SupportsKeyAndGetItem is invariant in the key type, it will produce a false positive when kwargs is for instance dict[Literal["foo", "bar"], int]. The correct test is:

Does there exist T <: str so that kwargs <: SupportsKeyAndGetItem[T, Any]

which can be checked with the new helper function.

Updated tests

I updated testLiteralKwargs to test:

  • both good and bad dict argument
  • both good and bad Mapping argument
  • both good and bad SupportsKeyAndGetitem argument

@github-actions

This comment has been minimized.

mypy/maptype.py Outdated
Comment on lines 29 to 30
from mypy.subtypes import is_subtype
from mypy.typeops import get_all_type_vars
Copy link
Contributor Author

Choose a reason for hiding this comment

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

had to put these there due to circular import issues

Copy link
Member

@ilevkivskyi ilevkivskyi Dec 17, 2025

Choose a reason for hiding this comment

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

This function should definitely not be in this module (ideally we shouldn't even have the existing typeops import below, but that is a separate story).

Also the scope of this function is misleadingly broad. It should probably accept a TypeInfo as target (which probably must be a protocol), and then use fill_typevars(...) as a constraint inference target.

Finally, it is worth doing some performance measurements, we don't want any visible slow-down for something that is only needed for rare edge cases.

Copy link
Contributor Author

@randolf-scholz randolf-scholz Dec 17, 2025

Choose a reason for hiding this comment

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

I put it there since the similar map_instance_to_supertype is in that module. Which module would be appropriate?

Regarding the scope, is this due to design philosophy? It seems useful that one could test for instance if something is a Mapping[T, SomeFixedType] for some T <: str.

If we were to use TypeInfo + fill_typevars, how do you suggest passing the necessary extra information that T <: str? I don't really understand what advantage making target a TypeInfo would give here, it seems like it would make things more complicated.

Copy link
Member

Choose a reason for hiding this comment

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

If we were to use TypeInfo + fill_typevars, how do you suggest passing the necessary extra information that T <: str?

Very simple: you don't pass it. Say I want to check, can Foo be some kind of Iterable, this functions may say "yes, it is Iterable[str]". Then you decide whether this solution is something that works for you or not.

@github-actions

This comment has been minimized.

@sterliakov
Copy link
Collaborator

Just FYI, #20435 could also benefit from this helper function to map to Iterable protocol in a sane fashion

@randolf-scholz
Copy link
Contributor Author

randolf-scholz commented Dec 19, 2025

OK, so I renamed the function to solve_as_subtype, dropped the direction argument and moved it to mypy/subtypes.

I ran some minimal benchmarks with hyperfine:

  1. hyperfine 'pytest -k kwargs -n 0'
    master: Time (mean ± σ): 5.836 s ± 0.209 s, Range (min … max): 5.558 s … 6.092 s 10 runs
    PR: Time (mean ± σ): 5.732 s ± 0.176 s, Range (min … max): 5.511 s … 5.991 s 10 runs
  2. hyperfine 'mypy tmp.py --no-incremental --cache-dir=/dev/null || true' (tmp.py holds testLiteralKwargs` unit test)
    master: Time (mean ± σ): 2.724 s ± 0.040 s, Range (min … max): 2.676 s … 2.788 s 10 runs
    PR: Time (mean ± σ): 2.702 s ± 0.038 s, Range (min … max): 2.646 s … 2.791 s 10 runs
  3. hyperfine --warmup 1 'mypy tmp.py --no-incremental --cache-dir=/dev/null || true' (tmp.py holds func(**{'a': 1, 'b': 2}) x 400)
    master: Time (mean ± σ): 2.742 s ± 0.037 s, Range (min … max): 2.679 s … 2.776 s 10 runs
    PR: Time (mean ± σ): 3.009 s ± 0.044 s, Range (min … max): 2.945 s … 3.079 s 10 runs

The first two show no difference (most time likely spent on setup/teardown), the last one show roughly a 10% performance degradation.

@github-actions

This comment has been minimized.

@randolf-scholz
Copy link
Contributor Author

A few more changes:

  • added a fast path for dict, test 3 from my previous comment is now comparably fast to master
  • added a fast fail path if the the argument is not a SupportsKeyAndGetitem.
  • added flavor-check for ParamSpec
  • added factorization over union + test (this was producing false positives on master as well: https://mypy-play.net/?mypy=master&python=3.12&gist=5bd4a2ba27f5d5edec09efb4486f65d9)
  • fixed confusing error message Argument after ** must be a mapping when the argument is a union of mappings.

@github-actions

This comment has been minimized.

Copy link
Member

@ilevkivskyi ilevkivskyi left a comment

Choose a reason for hiding this comment

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

Here are some more comments.

"""

# 1. get type vars of target
tvars = get_all_type_vars(target)
Copy link
Member

Choose a reason for hiding this comment

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

A caller may accidentally pass a type with some unrelated type variables (for example inside a generic function or class). As I said before, target should really be a TypeInfo.

Copy link
Contributor Author

@randolf-scholz randolf-scholz Jan 3, 2026

Choose a reason for hiding this comment

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

The applytype functions should check the validity of solution and give an appropriate error (i.e. bad solution vs no solution).

Very simple: you don't pass it. Say I want to check, can Foo be some kind of Iterable, this functions may say "yes, it is Iterable[str]". Then you decide whether this solution is something that works for you or not.

I believe this may be an incorrect procedure in general; it seems to implicitly assume that if S={A[X] <: B[X, Y, Z], X <: Y} is solvable, then a solution can be found by first solving X⁎=solve({A[X] <: B[X, Y, Z]}, X) and then considering X⁎ & Y if necessary.

This seems to work in simple cases, for instance, say we need to solve X <: Y such that list[X] <: Iterable[Z]. The solver will return X⁎ = Z. If Z <: Y, then X⁎=Z is fine, and otherwise X⁎&Y / meet(X⁎, Y) is a solution.

But I believe we need to include the upper bound X<:Y in general to find a correct solution in the first place. Take for instance X <: int and list[X] <: list[str] | list[int]. Without the X <: int constraint, the solver may pick1 X⁎=str, but neither str not str & int (↯) are a proper solution of the joint constraints {list[X] <: list[str] | list[int], X <: int}. The proper solution is X⁎=int.

The recursive case when B explicitly depends on X may be problematic as well.

Footnotes

  1. in fact, mypy seems to eagerly select the constraints here: https://github.com/python/mypy/blob/0cc21d99b8ab0c587fca66b697a50e6b59abf06d/mypy/constraints.py#L399-L408

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I opened #20516 with a bug report that reproduces this incorrect behavior.

Copy link
Member

Choose a reason for hiding this comment

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

That issue has literary nothing to do with the current discussion (it is actually a good illustration of the confusion I mentioned). In those examples you have in #20516, X is not something that needs to be solved for. It is simply an unrelated type like anything else (for example, exactly same problem appears if I have class X(int): ...). In that issue we solve for T in def list[T](...) -> ..., that one doesn't have any non-trivial upper bound.

Anyway, do you really think you can convince someone who has been working on mypy for 10 years that you know better how it should work?

Copy link
Contributor Author

@randolf-scholz randolf-scholz Jan 4, 2026

Choose a reason for hiding this comment

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

Anyway, do you really think you can convince someone who has been working on mypy for 10 years that you know better how it should work?

No, but I'd really like to understand why it should work that way, and what is the error in my thought process in #20419 (comment), so I can learn and grow.

mypy/maptype.py Outdated
Comment on lines 29 to 30
from mypy.subtypes import is_subtype
from mypy.typeops import get_all_type_vars
Copy link
Member

Choose a reason for hiding this comment

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

If we were to use TypeInfo + fill_typevars, how do you suggest passing the necessary extra information that T <: str?

Very simple: you don't pass it. Say I want to check, can Foo be some kind of Iterable, this functions may say "yes, it is Iterable[str]". Then you decide whether this solution is something that works for you or not.

Co-authored-by: Ivan Levkivskyi <levkivskyi@gmail.com>
@github-actions
Copy link
Contributor

github-actions bot commented Jan 2, 2026

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive error on **kwargs with Mapping[Literal, Any] type

3 participants