From 6a8f270cfbd7c361cbd139ef37fb77f3c2f23d08 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 5 Sep 2025 23:44:44 +0100 Subject: [PATCH] Traverse ParamSpec prefix where we should --- mypy/checkexpr.py | 2 +- mypy/erasetype.py | 1 + mypy/fixup.py | 1 + mypy/indirection.py | 1 + mypy/server/astdiff.py | 1 + mypy/server/astmerge.py | 1 + mypy/server/deps.py | 17 ++----- mypy/type_visitor.py | 2 +- mypy/typeanal.py | 2 +- mypy/typetraverser.py | 1 + test-data/unit/check-incremental.test | 67 +++++++++++++++++++++++++++ test-data/unit/fine-grained.test | 33 +++++++++++++ 12 files changed, 114 insertions(+), 15 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 2e5cf6e544d5..835eeb725394 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -6435,7 +6435,7 @@ def visit_type_var(self, t: TypeVarType) -> bool: def visit_param_spec(self, t: ParamSpecType) -> bool: default = [t.default] if t.has_default() else [] - return self.query_types([t.upper_bound, *default]) + return self.query_types([t.upper_bound, *default, t.prefix]) def visit_type_var_tuple(self, t: TypeVarTupleType) -> bool: default = [t.default] if t.has_default() else [] diff --git a/mypy/erasetype.py b/mypy/erasetype.py index 3f33ea1648f0..6645bcf916d9 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -222,6 +222,7 @@ def visit_type_var_tuple(self, t: TypeVarTupleType) -> Type: return t def visit_param_spec(self, t: ParamSpecType) -> Type: + # TODO: we should probably preserve prefix here. if self.erase_id is None or self.erase_id(t.id): return self.replacement return t diff --git a/mypy/fixup.py b/mypy/fixup.py index bec5929ad4b1..260c0f84cf1b 100644 --- a/mypy/fixup.py +++ b/mypy/fixup.py @@ -347,6 +347,7 @@ def visit_type_var(self, tvt: TypeVarType) -> None: def visit_param_spec(self, p: ParamSpecType) -> None: p.upper_bound.accept(self) p.default.accept(self) + p.prefix.accept(self) def visit_type_var_tuple(self, t: TypeVarTupleType) -> None: t.tuple_fallback.accept(self) diff --git a/mypy/indirection.py b/mypy/indirection.py index 06a158818fbe..88258b94d94a 100644 --- a/mypy/indirection.py +++ b/mypy/indirection.py @@ -93,6 +93,7 @@ def visit_type_var(self, t: types.TypeVarType) -> None: def visit_param_spec(self, t: types.ParamSpecType) -> None: self._visit(t.upper_bound) self._visit(t.default) + self._visit(t.prefix) def visit_type_var_tuple(self, t: types.TypeVarTupleType) -> None: self._visit(t.upper_bound) diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index 1df85a163e0f..25542ce37588 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -435,6 +435,7 @@ def visit_param_spec(self, typ: ParamSpecType) -> SnapshotItem: typ.flavor, snapshot_type(typ.upper_bound), snapshot_type(typ.default), + snapshot_type(typ.prefix), ) def visit_type_var_tuple(self, typ: TypeVarTupleType) -> SnapshotItem: diff --git a/mypy/server/astmerge.py b/mypy/server/astmerge.py index 33e2d2b799cb..cda1d20fb8e4 100644 --- a/mypy/server/astmerge.py +++ b/mypy/server/astmerge.py @@ -489,6 +489,7 @@ def visit_type_var(self, typ: TypeVarType) -> None: def visit_param_spec(self, typ: ParamSpecType) -> None: typ.upper_bound.accept(self) typ.default.accept(self) + typ.prefix.accept(self) def visit_type_var_tuple(self, typ: TypeVarTupleType) -> None: typ.upper_bound.accept(self) diff --git a/mypy/server/deps.py b/mypy/server/deps.py index b994a214f67a..9d4445a1f7e7 100644 --- a/mypy/server/deps.py +++ b/mypy/server/deps.py @@ -1037,10 +1037,8 @@ def visit_type_var(self, typ: TypeVarType) -> list[str]: triggers = [] if typ.fullname: triggers.append(make_trigger(typ.fullname)) - if typ.upper_bound: - triggers.extend(self.get_type_triggers(typ.upper_bound)) - if typ.default: - triggers.extend(self.get_type_triggers(typ.default)) + triggers.extend(self.get_type_triggers(typ.upper_bound)) + triggers.extend(self.get_type_triggers(typ.default)) for val in typ.values: triggers.extend(self.get_type_triggers(val)) return triggers @@ -1049,22 +1047,17 @@ def visit_param_spec(self, typ: ParamSpecType) -> list[str]: triggers = [] if typ.fullname: triggers.append(make_trigger(typ.fullname)) - if typ.upper_bound: - triggers.extend(self.get_type_triggers(typ.upper_bound)) - if typ.default: - triggers.extend(self.get_type_triggers(typ.default)) triggers.extend(self.get_type_triggers(typ.upper_bound)) + triggers.extend(self.get_type_triggers(typ.default)) + triggers.extend(self.get_type_triggers(typ.prefix)) return triggers def visit_type_var_tuple(self, typ: TypeVarTupleType) -> list[str]: triggers = [] if typ.fullname: triggers.append(make_trigger(typ.fullname)) - if typ.upper_bound: - triggers.extend(self.get_type_triggers(typ.upper_bound)) - if typ.default: - triggers.extend(self.get_type_triggers(typ.default)) triggers.extend(self.get_type_triggers(typ.upper_bound)) + triggers.extend(self.get_type_triggers(typ.default)) return triggers def visit_unpack_type(self, typ: UnpackType) -> list[str]: diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 65051ddbab67..15494393cae6 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -525,7 +525,7 @@ def visit_type_var(self, t: TypeVarType, /) -> bool: return self.query_types([t.upper_bound, t.default] + t.values) def visit_param_spec(self, t: ParamSpecType, /) -> bool: - return self.query_types([t.upper_bound, t.default]) + return self.query_types([t.upper_bound, t.default, t.prefix]) def visit_type_var_tuple(self, t: TypeVarTupleType, /) -> bool: return self.query_types([t.upper_bound, t.default]) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 7429030573a3..658730414763 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -2615,7 +2615,7 @@ def visit_type_var(self, t: TypeVarType) -> None: self.process_types([t.upper_bound, t.default] + t.values) def visit_param_spec(self, t: ParamSpecType) -> None: - self.process_types([t.upper_bound, t.default]) + self.process_types([t.upper_bound, t.default, t.prefix]) def visit_type_var_tuple(self, t: TypeVarTupleType) -> None: self.process_types([t.upper_bound, t.default]) diff --git a/mypy/typetraverser.py b/mypy/typetraverser.py index 047c5caf6dae..abd0f6bf3bdf 100644 --- a/mypy/typetraverser.py +++ b/mypy/typetraverser.py @@ -64,6 +64,7 @@ def visit_type_var(self, t: TypeVarType, /) -> None: t.default.accept(self) def visit_param_spec(self, t: ParamSpecType, /) -> None: + # TODO: do we need to traverse prefix here? t.default.accept(self) def visit_parameters(self, t: Parameters, /) -> None: diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index defe7402730f..66c23af7ed9c 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -6897,3 +6897,70 @@ import does_not_exist [builtins fixtures/ops.pyi] [out] [out2] + +[case testIncrementalNoCrashOnParamSpecPrefixUpdateMethod] +import impl +[file impl.py] +from typing_extensions import ParamSpec +from lib import Sub + +P = ParamSpec("P") +class Impl(Sub[P]): + def test(self, *args: P.args, **kwargs: P.kwargs) -> None: + self.meth(1, *args, **kwargs) + +[file impl.py.2] +from typing_extensions import ParamSpec +from lib import Sub + +P = ParamSpec("P") +class Impl(Sub[P]): + def test(self, *args: P.args, **kwargs: P.kwargs) -> None: + self.meth("no", *args, **kwargs) + +[file lib.py] +from typing import Generic +from typing_extensions import ParamSpec, Concatenate + +P = ParamSpec("P") +class Base(Generic[P]): + def meth(self, *args: P.args, **kwargs: P.kwargs) -> None: ... +class Sub(Base[Concatenate[int, P]]): ... +[builtins fixtures/paramspec.pyi] +[out] +[out2] +tmp/impl.py:7: error: Argument 1 to "meth" of "Base" has incompatible type "str"; expected "int" + +[case testIncrementalNoCrashOnParamSpecPrefixUpdateMethodAlias] +import impl +[file impl.py] +from typing_extensions import ParamSpec +from lib import Sub + +P = ParamSpec("P") +class Impl(Sub[P]): + def test(self, *args: P.args, **kwargs: P.kwargs) -> None: + self.alias(1, *args, **kwargs) + +[file impl.py.2] +from typing_extensions import ParamSpec +from lib import Sub + +P = ParamSpec("P") +class Impl(Sub[P]): + def test(self, *args: P.args, **kwargs: P.kwargs) -> None: + self.alias("no", *args, **kwargs) + +[file lib.py] +from typing import Generic +from typing_extensions import ParamSpec, Concatenate + +P = ParamSpec("P") +class Base(Generic[P]): + def meth(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + alias = meth +class Sub(Base[Concatenate[int, P]]): ... +[builtins fixtures/paramspec.pyi] +[out] +[out2] +tmp/impl.py:7: error: Argument 1 has incompatible type "str"; expected "int" diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 888b7bc7e97f..1bddee0e5ed2 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -11485,3 +11485,36 @@ class A: [out] == main:3: error: Too few arguments + +[case testFineGrainedParamSpecPrefixUpdateMethod] +import impl +[file impl.py] +from typing_extensions import ParamSpec +from lib import Sub + +P = ParamSpec("P") +class Impl(Sub[P]): + def test(self, *args: P.args, **kwargs: P.kwargs) -> None: + self.meth(1, *args, **kwargs) + +[file lib.py] +from typing import Generic +from typing_extensions import ParamSpec, Concatenate + +P = ParamSpec("P") +class Base(Generic[P]): + def meth(self, *args: P.args, **kwargs: P.kwargs) -> None: ... +class Sub(Base[Concatenate[int, P]]): ... + +[file lib.py.2] +from typing import Generic +from typing_extensions import ParamSpec, Concatenate + +P = ParamSpec("P") +class Base(Generic[P]): + def meth(self, *args: P.args, **kwargs: P.kwargs) -> None: ... +class Sub(Base[Concatenate[str, P]]): ... +[builtins fixtures/paramspec.pyi] +[out] +== +impl.py:7: error: Argument 1 to "meth" of "Base" has incompatible type "int"; expected "str"