diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88de9341..93b706d7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,6 +31,7 @@ unreleased * introduce Y027 (Python 2-incompatible extension of Y022) * all errors are now enabled by default * introduce Y029 (never define ``__repr__`` or ``__str__``) +* introduce Y030 (use ``Literal['foo', 'bar']`` instead of ``Literal['foo'] | Literal['bar']``) 20.10.0 ~~~~~~~ diff --git a/README.rst b/README.rst index d1fdbcdd..0bfb6865 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,9 @@ currently emitted: * Y029: It is almost always redundant to define ``__str__`` or ``__repr__`` in a stub file, as the signatures are almost always identical to ``object.__str__``and ``object.__repr__``. +* Y030: Union expressions should never have more than one ``Literal`` member, + as ``Literal[1] | Literal[2]`` is semantically identical to + ``Literal[1, 2]``. Many error codes enforce modern conventions, and some cannot yet be used in all cases: diff --git a/pyi.py b/pyi.py index d7800ad0..78fb7801 100644 --- a/pyi.py +++ b/pyi.py @@ -421,9 +421,52 @@ def _check_union_members(self, members: Sequence[ast.expr]) -> None: for member in members: members_by_dump.setdefault(ast.dump(member), []).append(member) - for members in members_by_dump.values(): - if len(members) >= 2: - self.error(members[1], Y016.format(unparse(members[1]))) + dupes_in_union = False + for member_list in members_by_dump.values(): + if len(member_list) >= 2: + self.error(member_list[1], Y016.format(unparse(member_list[1]))) + dupes_in_union = True + + if not dupes_in_union: + self._check_for_multiple_literals(members) + + def _check_for_multiple_literals(self, members: Sequence[ast.expr]) -> None: + literals_in_union, non_literals_in_union = [], [] + + for member in members: + if ( + isinstance(member, ast.Subscript) + and isinstance(member.value, ast.Name) + and member.value.id == "Literal" + ): + literals_in_union.append(member.slice) + else: + non_literals_in_union.append(member) + + if len(literals_in_union) < 2: + return + + new_literal_members: list[ast.expr] = [] + + for literal in literals_in_union: + if sys.version_info >= (3, 9): + contents = literal + else: + contents = literal.value + + if isinstance(contents, ast.Tuple): + new_literal_members.extend(contents.elts) + else: + new_literal_members.append(contents) + + new_literal_slice = unparse(ast.Tuple(new_literal_members)).strip("()") + + if non_literals_in_union: + suggestion = f'Combine them into one, e.g. "Literal[{new_literal_slice}]".' + else: + suggestion = f'Use a single Literal, e.g. "Literal[{new_literal_slice}]".' + + self.error(members[0], Y030.format(suggestion=suggestion)) def visit_BinOp(self, node: ast.BinOp) -> None: if not isinstance(node.op, ast.BitOr): @@ -905,3 +948,4 @@ def parse_options(cls, optmanager, options, extra_args): Y027 = 'Y027 Use {good_cls_name} instead of "{bad_cls_alias}"' Y028 = "Y028 Use class-based syntax for NamedTuples" Y029 = "Y029 Defining __repr__ or __str__ in a stub is almost always redundant" +Y030 = "Y030 Multiple Literal members in a union. {suggestion}" diff --git a/tests/union_duplicates.pyi b/tests/union_duplicates.pyi index 564baba4..3c658fc0 100644 --- a/tests/union_duplicates.pyi +++ b/tests/union_duplicates.pyi @@ -1,4 +1,5 @@ from typing import Union +from typing_extensions import Literal, TypeAlias def f1_pipe(x: int | str) -> None: ... @@ -21,3 +22,10 @@ def f4_union(x: Union[int, None, int]) -> None: # Y016 Duplicate union member " ... def f5_union(x: Union[int, int, None]) -> None: # Y016 Duplicate union member "int" ... + + +just_literals_subscript_union: Union[Literal[1], Literal[2]] # Y030 Multiple Literal members in a union. Use a single Literal, e.g. "Literal[1, 2]". +mixed_subscript_union: Union[str, Literal['foo'], Literal['bar']] # Y030 Multiple Literal members in a union. Combine them into one, e.g. "Literal['foo', 'bar']". +just_literals_pipe_union: TypeAlias = Literal[True] | Literal['idk'] # Y030 Multiple Literal members in a union. Use a single Literal, e.g. "Literal[True, 'idk']". +mixed_pipe_union: TypeAlias = Union[Literal[966], int, Literal['baz']] # Y030 Multiple Literal members in a union. Combine them into one, e.g. "Literal[966, 'baz']". +many_literal_members_but_needs_combining: TypeAlias = int | Literal['a', 'b'] | Literal['baz'] # Y030 Multiple Literal members in a union. Combine them into one, e.g. "Literal['a', 'b', 'baz']".