Skip to content

Commit

Permalink
Use X | Y union syntax in error messages (#15102)
Browse files Browse the repository at this point in the history
This should fix #15082:
If the python version is set to 3.10 or later union error reports are
going to be displayed using the `X | Y` syntax.
If None is found in a union it is shown as the last element in order to
improve visibility (`Union[bool, None, str]` becomes `"bool | str |
None"`.
To achieve this an option `use_or_syntax()` is created that is set to
true if the python version is 3.10 or later.

For testing a hidden flag --force-union-syntax is used that sets the
option to false.
  • Loading branch information
omarsilva1 authored Apr 24, 2023
1 parent fe8873f commit 334daca
Show file tree
Hide file tree
Showing 8 changed files with 103 additions and 6 deletions.
4 changes: 4 additions & 0 deletions mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,10 @@ def add_invertible_flag(
"--force-uppercase-builtins", default=False, help=argparse.SUPPRESS, group=none_group
)

add_invertible_flag(
"--force-union-syntax", default=False, help=argparse.SUPPRESS, group=none_group
)

lint_group = parser.add_argument_group(
title="Configuring warnings",
description="Detect code that is sound but redundant or problematic.",
Expand Down
31 changes: 26 additions & 5 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,12 @@ def format(typ: Type) -> str:
def format_list(types: Sequence[Type]) -> str:
return ", ".join(format(typ) for typ in types)

def format_union(types: Sequence[Type]) -> str:
formatted = [format(typ) for typ in types if format(typ) != "None"]
if any(format(typ) == "None" for typ in types):
formatted.append("None")
return " | ".join(formatted)

def format_literal_value(typ: LiteralType) -> str:
if typ.is_enum_literal():
underlying_type = format(typ.fallback)
Expand Down Expand Up @@ -2457,9 +2463,17 @@ def format_literal_value(typ: LiteralType) -> str:
)

if len(union_items) == 1 and isinstance(get_proper_type(union_items[0]), NoneType):
return f"Optional[{literal_str}]"
return (
f"{literal_str} | None"
if options.use_or_syntax()
else f"Optional[{literal_str}]"
)
elif union_items:
return f"Union[{format_list(union_items)}, {literal_str}]"
return (
f"{literal_str} | {format_union(union_items)}"
if options.use_or_syntax()
else f"Union[{format_list(union_items)}, {literal_str}]"
)
else:
return literal_str
else:
Expand All @@ -2470,10 +2484,17 @@ def format_literal_value(typ: LiteralType) -> str:
)
if print_as_optional:
rest = [t for t in typ.items if not isinstance(get_proper_type(t), NoneType)]
return f"Optional[{format(rest[0])}]"
return (
f"{format(rest[0])} | None"
if options.use_or_syntax()
else f"Optional[{format(rest[0])}]"
)
else:
s = f"Union[{format_list(typ.items)}]"

s = (
format_union(typ.items)
if options.use_or_syntax()
else f"Union[{format_list(typ.items)}]"
)
return s
elif isinstance(typ, NoneType):
return "None"
Expand Down
6 changes: 6 additions & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,18 @@ def __init__(self) -> None:
self.disable_memoryview_promotion = False

self.force_uppercase_builtins = False
self.force_union_syntax = False

def use_lowercase_names(self) -> bool:
if self.python_version >= (3, 9):
return not self.force_uppercase_builtins
return False

def use_or_syntax(self) -> bool:
if self.python_version >= (3, 10):
return not self.force_union_syntax
return False

# To avoid breaking plugin compatibility, keep providing new_semantic_analyzer
@property
def new_semantic_analyzer(self) -> bool:
Expand Down
1 change: 1 addition & 0 deletions mypy/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ def parse_options(
options.error_summary = False
options.hide_error_codes = True
options.force_uppercase_builtins = True
options.force_union_syntax = True

# Allow custom python version to override testfile_pyversion.
if all(flag.split("=")[0] not in ["--python-version", "-2", "--py2"] for flag in flag_list):
Expand Down
2 changes: 2 additions & 0 deletions mypy/test/testcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ def run_case_once(
options.allow_empty_bodies = not testcase.name.endswith("_no_empty")
if "lowercase" not in testcase.file:
options.force_uppercase_builtins = True
if "union-error" not in testcase.file:
options.force_union_syntax = True

if incremental_step and options.incremental:
# Don't overwrite # flags: --no-incremental in incremental test cases
Expand Down
2 changes: 2 additions & 0 deletions mypy/test/testcmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
args.append("--allow-empty-bodies")
if "--no-force-uppercase-builtins" not in args:
args.append("--force-uppercase-builtins")
if "--no-force-union-syntax" not in args:
args.append("--force-union-syntax")
# Type check the program.
fixed = [python3_path, "-m", "mypy"]
env = os.environ.copy()
Expand Down
61 changes: 61 additions & 0 deletions test-data/unit/check-union-error-syntax.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
[case testUnionErrorSyntax]
# flags: --python-version 3.10 --no-force-union-syntax
from typing import Union
x : Union[bool, str]
x = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "bool | str")

[case testOrErrorSyntax]
# flags: --python-version 3.10 --force-union-syntax
from typing import Union
x : Union[bool, str]
x = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "Union[bool, str]")

[case testOrNoneErrorSyntax]
# flags: --python-version 3.10 --no-force-union-syntax
from typing import Union
x : Union[bool, None]
x = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "bool | None")

[case testOptionalErrorSyntax]
# flags: --python-version 3.10 --force-union-syntax
from typing import Union
x : Union[bool, None]
x = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "Optional[bool]")

[case testNoneAsFinalItem]
# flags: --python-version 3.10 --no-force-union-syntax
from typing import Union
x : Union[bool, None, str]
x = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "bool | str | None")

[case testLiteralOrErrorSyntax]
# flags: --python-version 3.10 --no-force-union-syntax
from typing import Union
from typing_extensions import Literal
x : Union[Literal[1], Literal[2], str]
x = 3 # E: Incompatible types in assignment (expression has type "Literal[3]", variable has type "Literal[1, 2] | str")
[builtins fixtures/tuple.pyi]

[case testLiteralUnionErrorSyntax]
# flags: --python-version 3.10 --force-union-syntax
from typing import Union
from typing_extensions import Literal
x : Union[Literal[1], Literal[2], str]
x = 3 # E: Incompatible types in assignment (expression has type "Literal[3]", variable has type "Union[str, Literal[1, 2]]")
[builtins fixtures/tuple.pyi]

[case testLiteralOrNoneErrorSyntax]
# flags: --python-version 3.10 --no-force-union-syntax
from typing import Union
from typing_extensions import Literal
x : Union[Literal[1], None]
x = 3 # E: Incompatible types in assignment (expression has type "Literal[3]", variable has type "Literal[1] | None")
[builtins fixtures/tuple.pyi]

[case testLiteralOptionalErrorSyntax]
# flags: --python-version 3.10 --force-union-syntax
from typing import Union
from typing_extensions import Literal
x : Union[Literal[1], None]
x = 3 # E: Incompatible types in assignment (expression has type "Literal[3]", variable has type "Optional[Literal[1]]")
[builtins fixtures/tuple.pyi]
2 changes: 1 addition & 1 deletion test-data/unit/daemon.test
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def bar() -> None:
foo(arg='xyz')

[case testDaemonGetType_python38]
$ dmypy start --log-file log.txt -- --follow-imports=error --no-error-summary
$ dmypy start --log-file log.txt -- --follow-imports=error --no-error-summary --python-version 3.8
Daemon started
$ dmypy inspect foo:1:2:3:4
Command "inspect" is only valid after a "check" command (that produces no parse errors)
Expand Down

0 comments on commit 334daca

Please sign in to comment.