From 1770ea10523d42e828f1078d0865703e1a4eab8c Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 13 Sep 2026 23:53:22 +0100 Subject: [PATCH 1/3] Add support for PEP 798 --- mypy/checkexpr.py | 58 +++++++++++++++++++++++------ mypy/fastparse.py | 12 ++++-- mypy/nativeparse.py | 5 ++- mypy/semanal.py | 2 + mypy/test/testcheck.py | 2 + test-data/unit/check-python315.test | 24 ++++++++++++ 6 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 test-data/unit/check-python315.test diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 4ed64e590950..647116706d32 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5980,9 +5980,25 @@ def check_generator_or_comprehension( upper_bound=self.object_type(), default=AnyType(TypeOfAny.from_omitted_generics), ) + if isinstance(gen.left_expr, StarExpr): + left_expr = gen.left_expr.expr + # Note: we wrap the argument type instead of using ARG_STAR kind, because logic + # in argmap.py doesn't work with structural subtypes of Iterable. + arg = self.chk.named_generic_type("typing.Iterable", [tv]) + # Motivation for inferring more unions in this case is two-fold: + # * In regular (non-star) case the constructor signature has bare type variable, + # thus hitting a special case in constraints solver that would naturally infer + # more unions. We want to match this behavior here. + # * This is a new syntax, so we can experiment with something + # we may want in long-term + force_infer_unions = True + else: + left_expr = gen.left_expr + arg = tv + force_infer_unions = False tv_list: list[Type] = [tv] constructor = CallableType( - tv_list, + [arg], [nodes.ARG_POS], [None], self.chk.named_generic_type(type_name, tv_list + additional_args), @@ -5990,7 +6006,13 @@ def check_generator_or_comprehension( name=id_for_messages, variables=[tv], ) - return self.check_call(constructor, [gen.left_expr], [nodes.ARG_POS], gen)[0] + if force_infer_unions: + old_infer_unions = type_state.infer_unions + type_state.infer_unions = True + res = self.check_call(constructor, [left_expr], [nodes.ARG_POS], gen) + if force_infer_unions: + type_state.infer_unions = old_infer_unions + return res[0] def visit_dictionary_comprehension(self, e: DictionaryComprehension) -> Type: """Type check a dictionary comprehension.""" @@ -6015,21 +6037,35 @@ def visit_dictionary_comprehension(self, e: DictionaryComprehension) -> Type: upper_bound=self.object_type(), default=AnyType(TypeOfAny.from_omitted_generics), ) + if e.key is None: + # Logic and motivation here is similar to check_generator_or_comprehension(). + arg_types = [self.chk.named_generic_type("typing.Mapping", [ktdef, vtdef])] + arg_kinds = [nodes.ARG_POS] + arg_names = [None] + args = [e.value] + force_infer_unions = True + else: + arg_types = [ktdef, vtdef] + arg_kinds = [nodes.ARG_POS, nodes.ARG_POS] + arg_names = [None, None] + args = [e.key, e.value] + force_infer_unions = False constructor = CallableType( - [ktdef, vtdef], - [nodes.ARG_POS, nodes.ARG_POS], - [None, None], + arg_types, + arg_kinds, + arg_names, self.chk.named_generic_type("builtins.dict", [ktdef, vtdef]), self.chk.named_type("builtins.function"), name="", variables=[ktdef, vtdef], ) - if e.key is None: - self.chk.fail("PEP 798 is not supported yet", e) - return AnyType(TypeOfAny.from_error) - return self.check_call( - constructor, [e.key, e.value], [nodes.ARG_POS, nodes.ARG_POS], e - )[0] + if force_infer_unions: + old_infer_unions = type_state.infer_unions + type_state.infer_unions = True + res = self.check_call(constructor, args, arg_kinds, e) + if force_infer_unions: + type_state.infer_unions = old_infer_unions + return res[0] def check_for_comp(self, e: GeneratorExpr | DictionaryComprehension) -> None: """Check the for_comp part of comprehensions. That is the part from 'for': diff --git a/mypy/fastparse.py b/mypy/fastparse.py index b3e54d0da794..a4aea55386ab 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -1569,9 +1569,15 @@ def visit_DictComp(self, n: ast3.DictComp) -> DictionaryComprehension: iters = [self.visit(c.iter) for c in n.generators] ifs_list = [self.translate_expr_list(c.ifs) for c in n.generators] is_async = [bool(c.is_async) for c in n.generators] - e = DictionaryComprehension( - self.visit(n.key), self.visit(n.value), targets, iters, ifs_list, is_async - ) + key = self.visit(n.key) + if n.value is not None: + value = self.visit(n.value) + else: + # Our convention for ** unpack in dictionary comprehension matches Ruff + # parser, while Python parser has an opposite one. + value = key # type: ignore[unreachable] + key = None + e = DictionaryComprehension(key, value, targets, iters, ifs_list, is_async) return self.set_line(e, n) # GeneratorExp(expr elt, comprehension* generators) diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index 00c3b0ff7e1b..5a5a24706877 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -1597,7 +1597,6 @@ def read_expression(state: State, data: ReadBuffer) -> Expression: expr = DictionaryComprehension(key, value, indices, sequences, condlists, is_async) read_loc(data, expr) if key is None: - # TODO: add similar check to other kinds of comprehensions. state.check_min_version("Unpacking in comprehensions", (3, 15), expr.line, expr.column) expect_end_tag(data) return expr @@ -1781,6 +1780,10 @@ def read_expression_list(state: State, data: ReadBuffer) -> list[Expression]: def read_generator_expr(state: State, data: ReadBuffer) -> GeneratorExpr: """Helper function to read comprehension data (shared by Generator, ListComp, SetComp)""" left_expr = read_expression(state, data) + if isinstance(left_expr, StarExpr): + state.check_min_version( + "Unpacking in comprehensions", (3, 15), left_expr.line, left_expr.column + ) n_generators = read_int(data) indices = [read_expression(state, data) for _ in range(n_generators)] sequences = [read_expression(state, data) for _ in range(n_generators)] diff --git a/mypy/semanal.py b/mypy/semanal.py index 8d9b001ae775..0ebdc7ce7e05 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6472,6 +6472,8 @@ def visit_dictionary_comprehension(self, expr: DictionaryComprehension) -> None: self.analyze_comp_for_2(expr) def visit_generator_expr(self, expr: GeneratorExpr) -> None: + if isinstance(expr.left_expr, StarExpr): + expr.left_expr.valid = True with self.enter(expr): self.analyze_comp_for(expr) expr.left_expr.accept(self) diff --git a/mypy/test/testcheck.py b/mypy/test/testcheck.py index f2bd21b87deb..d056bb6e1afd 100644 --- a/mypy/test/testcheck.py +++ b/mypy/test/testcheck.py @@ -52,6 +52,8 @@ typecheck_files.remove("check-python313.test") if sys.version_info < (3, 14): typecheck_files.remove("check-python314.test") +if sys.version_info < (3, 15): + typecheck_files.remove("check-python315.test") class TypeCheckSuite(DataSuite): diff --git a/test-data/unit/check-python315.test b/test-data/unit/check-python315.test new file mode 100644 index 000000000000..3de9f4780acf --- /dev/null +++ b/test-data/unit/check-python315.test @@ -0,0 +1,24 @@ +[case testStarUnpackInComprehensionBasicsNative] +# flags: --native-parser + +def test(x: list[list[int]], y: list[dict[str, int]]) -> None: + reveal_type([*i for i in x]) # N: Revealed type is "builtins.list[builtins.int]" + reveal_type({*i for i in x}) # N: Revealed type is "builtins.set[builtins.int]" + reveal_type({**d for d in y}) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" +[builtins fixtures/isinstancelist.pyi] + +[case testStarUnpackInComprehensionBasicsOld] +# flags: --no-native-parser + +def test(x: list[list[int]], y: list[dict[str, int]]) -> None: + reveal_type([*i for i in x]) # N: Revealed type is "builtins.list[builtins.int]" + reveal_type({*i for i in x}) # N: Revealed type is "builtins.set[builtins.int]" + reveal_type({**d for d in y}) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" +[builtins fixtures/isinstancelist.pyi] + +[case testStarUnpackInComprehensionUnion] +def test(x: list[list[int]] | list[list[str]], y: list[dict[str, int]] | list[dict[str, str]]) -> None: + reveal_type([*i for i in x]) # N: Revealed type is "builtins.list[builtins.int | builtins.str]" + reveal_type({*i for i in x}) # N: Revealed type is "builtins.set[builtins.int | builtins.str]" + reveal_type({**d for d in y}) # N: Revealed type is "builtins.dict[builtins.str | builtins.str, builtins.int | builtins.str]" +[builtins fixtures/isinstancelist.pyi] From 72f852f7bd4080a7d8d3dc1b34ab9d7564f220c4 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 18 Sep 2026 00:29:57 +0100 Subject: [PATCH 2/3] Make sure to fail gracefully in mypyc --- mypyc/irbuild/expression.py | 9 +++++++++ mypyc/test-data/irbuild-python315.test | 6 ++++++ mypyc/test/test_irbuild.py | 3 +++ 3 files changed, 18 insertions(+) create mode 100644 mypyc/test-data/irbuild-python315.test diff --git a/mypyc/irbuild/expression.py b/mypyc/irbuild/expression.py index 96c64092a95d..3419288db9da 100644 --- a/mypyc/irbuild/expression.py +++ b/mypyc/irbuild/expression.py @@ -1390,6 +1390,9 @@ def _visit_display( def transform_list_comprehension(builder: IRBuilder, o: ListComprehension) -> Value: + if isinstance(o.generator.left_expr, StarExpr): + builder.error("PEP 798 is not supported yet", o.line) + return builder.none() gen = o.generator if gen in builder.comprehension_to_fitem: return _translate_comprehension_with_scope( @@ -1399,6 +1402,9 @@ def transform_list_comprehension(builder: IRBuilder, o: ListComprehension) -> Va def transform_set_comprehension(builder: IRBuilder, o: SetComprehension) -> Value: + if isinstance(o.generator.left_expr, StarExpr): + builder.error("PEP 798 is not supported yet", o.line) + return builder.none() gen = o.generator if gen in builder.comprehension_to_fitem: return _translate_comprehension_with_scope( @@ -1473,6 +1479,9 @@ def get_arg(arg: Expression | None) -> Value: def transform_generator_expr(builder: IRBuilder, o: GeneratorExpr) -> Value: + if isinstance(o.left_expr, StarExpr): + builder.error("PEP 798 is not supported yet", o.line) + return builder.none() builder.warning("Treating generator comprehension as list", o.line) if o in builder.comprehension_to_fitem: return builder.primitive_op( diff --git a/mypyc/test-data/irbuild-python315.test b/mypyc/test-data/irbuild-python315.test new file mode 100644 index 000000000000..e6a26a3a619c --- /dev/null +++ b/mypyc/test-data/irbuild-python315.test @@ -0,0 +1,6 @@ +[case testPEP798FailGracefully_python3_15] +x = [[1]] +y = [*l for l in x] # E: PEP 798 is not supported yet + +dx = [{1: 1}] +dy = {**d for d in dx} # E: PEP 798 is not supported yet diff --git a/mypyc/test/test_irbuild.py b/mypyc/test/test_irbuild.py index 6608e6db8e7b..1484d47ce955 100644 --- a/mypyc/test/test_irbuild.py +++ b/mypyc/test/test_irbuild.py @@ -73,6 +73,9 @@ if sys.version_info >= (3, 14): files.append("irbuild-python314.test") +if sys.version_info >= (3, 15): + files.append("irbuild-python315.test") + class TestGenOps(MypycDataSuite): files = files From 949820f4cf0423a930fce05643d3588471799456 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 18 Sep 2026 15:53:41 +0100 Subject: [PATCH 3/3] More user-friendly error --- mypyc/irbuild/expression.py | 8 ++++---- mypyc/test-data/irbuild-python315.test | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mypyc/irbuild/expression.py b/mypyc/irbuild/expression.py index 3419288db9da..8bc4568e0d47 100644 --- a/mypyc/irbuild/expression.py +++ b/mypyc/irbuild/expression.py @@ -1391,7 +1391,7 @@ def _visit_display( def transform_list_comprehension(builder: IRBuilder, o: ListComprehension) -> Value: if isinstance(o.generator.left_expr, StarExpr): - builder.error("PEP 798 is not supported yet", o.line) + builder.error("Unpacking in comprehensions is not supported yet", o.line) return builder.none() gen = o.generator if gen in builder.comprehension_to_fitem: @@ -1403,7 +1403,7 @@ def transform_list_comprehension(builder: IRBuilder, o: ListComprehension) -> Va def transform_set_comprehension(builder: IRBuilder, o: SetComprehension) -> Value: if isinstance(o.generator.left_expr, StarExpr): - builder.error("PEP 798 is not supported yet", o.line) + builder.error("Unpacking in comprehensions is not supported yet", o.line) return builder.none() gen = o.generator if gen in builder.comprehension_to_fitem: @@ -1430,7 +1430,7 @@ def gen_inner_stmts() -> None: if o.key is not None: k = builder.accept(o.key) else: - builder.error("PEP 798 is not supported yet", o.line) + builder.error("Unpacking in comprehensions is not supported yet", o.line) k = builder.none() v = builder.accept(o.value) builder.call_c(exact_dict_set_item_op, [builder.read(d, o.line), k, v], o.line) @@ -1480,7 +1480,7 @@ def get_arg(arg: Expression | None) -> Value: def transform_generator_expr(builder: IRBuilder, o: GeneratorExpr) -> Value: if isinstance(o.left_expr, StarExpr): - builder.error("PEP 798 is not supported yet", o.line) + builder.error("Unpacking in comprehensions is not supported yet", o.line) return builder.none() builder.warning("Treating generator comprehension as list", o.line) if o in builder.comprehension_to_fitem: diff --git a/mypyc/test-data/irbuild-python315.test b/mypyc/test-data/irbuild-python315.test index e6a26a3a619c..297484aa2952 100644 --- a/mypyc/test-data/irbuild-python315.test +++ b/mypyc/test-data/irbuild-python315.test @@ -1,6 +1,6 @@ [case testPEP798FailGracefully_python3_15] x = [[1]] -y = [*l for l in x] # E: PEP 798 is not supported yet +y = [*l for l in x] # E: Unpacking in comprehensions is not supported yet dx = [{1: 1}] -dy = {**d for d in dx} # E: PEP 798 is not supported yet +dy = {**d for d in dx} # E: Unpacking in comprehensions is not supported yet