Repository: python/mypy

Issue:
Literal[negative int]
Python 3.7.4
typing_extensions 3.7.4
mypy git tip ( 0.750+dev.78f2df9d46198563106dd32cb4494678e97d71eb):

mypy returns false positives for negative integer literals.
errors inlined for convenience:
```python
from typing_extensions import Literal


a: Literal[-2] = -2  # error: Incompatible types in assignment (expression has type "int", variable has type "Literal[-2]")
b: Literal[-1] = -1  # error: Incompatible types in assignment (expression has type "int", variable has type "Literal[-1]")
c: Literal[0] = 0
d: Literal[1] = 1
e: Literal[2] = 2
```



Discussion:
Apparently nobody thought of negative integer literals! This clearly looks like a bug. Either negative literals should be rejected in literal types or they should work as expected.

cc @Michael0x2a 
By coincidence, I also ran into this the other day -- the problem is expressions like `-1` are actually `(1).__neg__()` and so we end up using the return type of just `__neg__()`, which is just int.

The easiest fix is probably to special-case the `__neg__` method for ints using a plugin. E.g. intercept `Instance` objects and copy over the `last_known_value` field to the return type or something.

(If somebody wants to try taking this, feel free. Otherwise, I'll probably work on this later next week.)
@Michael0x2a I'd like to take this but I may need more info about it, i.e. the very first file I need to look to.
@TH3CHARLie -- sure. 

The change you're trying to make is to return a new callback in the `get_method_hooks` method here: https://github.com/python/mypy/blob/master/mypy/plugins/default.py#L52.

I recommend skimming through some of the existing method hook callbacks to get a feel for how they work, but on a high level what you'll want to do is to modify the return type if the underlying object is:

1. An [`Instance` object](https://github.com/python/mypy/blob/master/mypy/types.py#L690) where the `last_known_value` field is set to an int [LiteralType object](https://github.com/python/mypy/blob/master/mypy/types.py#L1538).
2. An int LiteralType object.

It doesn't matter too much where you add test cases for this, but probably https://github.com/python/mypy/blob/master/test-data/unit/check-literal.test is a reasonable home for them. The [Tests README](https://github.com/python/mypy/blob/master/test-data/unit/README.md) has more info on writing/running test cases.

Resolving patch:
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -251,6 +251,10 @@ def __init__(self, errors: Errors, modules: Dict[str, MypyFile], options: Option
         # argument through various `checker` and `checkmember` functions.
         self._is_final_def = False
 
+    @property
+    def type_context(self) -> List[Optional[Type]]:
+        return self.expr_checker.type_context
+
     def reset(self) -> None:
         """Cleanup stale state that might be left over from a typechecking run.
 
diff --git a/mypy/plugin.py b/mypy/plugin.py
--- a/mypy/plugin.py
+++ b/mypy/plugin.py
@@ -216,6 +216,12 @@ class CheckerPluginInterface:
     options = None  # type: Options
     path = None  # type: str
 
+    # Type context for type inference
+    @abstractproperty
+    def type_context(self) -> List[Optional[Type]]:
+        """Return the type context of the plugin"""
+        raise NotImplementedError
+
     @abstractmethod
     def fail(self, msg: str, ctx: Context, *, code: Optional[ErrorCode] = None) -> None:
         """Emit an error message at given location."""
diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py
--- a/mypy/plugins/default.py
+++ b/mypy/plugins/default.py
@@ -10,10 +10,11 @@
 from mypy.plugins.common import try_getting_str_literals
 from mypy.types import (
     Type, Instance, AnyType, TypeOfAny, CallableType, NoneType, TypedDictType,
-    TypeVarType, TPDICT_FB_NAMES, get_proper_type
+    TypeVarType, TPDICT_FB_NAMES, get_proper_type, LiteralType
 )
 from mypy.subtypes import is_subtype
 from mypy.typeops import make_simplified_union
+from mypy.checkexpr import is_literal_type_like
 
 
 class DefaultPlugin(Plugin):
@@ -57,6 +58,8 @@ def get_method_hook(self, fullname: str
             return typed_dict_get_callback
         elif fullname == 'builtins.int.__pow__':
             return int_pow_callback
+        elif fullname == 'builtins.int.__neg__':
+            return int_neg_callback
         elif fullname in set(n + '.setdefault' for n in TPDICT_FB_NAMES):
             return typed_dict_setdefault_callback
         elif fullname in set(n + '.pop' for n in TPDICT_FB_NAMES):
@@ -417,3 +420,30 @@ def int_pow_callback(ctx: MethodContext) -> Type:
         else:
             return ctx.api.named_generic_type('builtins.float', [])
     return ctx.default_return_type
+
+
+def int_neg_callback(ctx: MethodContext) -> Type:
+    """Infer a more precise return type for int.__neg__.
+
+    This is mainly used to infer the return type as LiteralType
+    if the original underlying object is a LiteralType object
+    """
+    if isinstance(ctx.type, Instance) and ctx.type.last_known_value is not None:
+        value = ctx.type.last_known_value.value
+        fallback = ctx.type.last_known_value.fallback
+        if isinstance(value, int):
+            if is_literal_type_like(ctx.api.type_context[-1]):
+                return LiteralType(value=-value, fallback=fallback)
+            else:
+                return ctx.type.copy_modified(last_known_value=LiteralType(
+                    value=-value,
+                    fallback=ctx.type,
+                    line=ctx.type.line,
+                    column=ctx.type.column,
+                ))
+    elif isinstance(ctx.type, LiteralType):
+        value = ctx.type.value
+        fallback = ctx.type.fallback
+        if isinstance(value, int):
+            return LiteralType(value=-value, fallback=fallback)
+    return ctx.default_return_type


Test patch:
diff --git a/test-data/unit/check-literal.test b/test-data/unit/check-literal.test
--- a/test-data/unit/check-literal.test
+++ b/test-data/unit/check-literal.test
@@ -3093,3 +3093,28 @@ def test() -> None:
     if x == 2:  # OK
         ...
 [builtins fixtures/bool.pyi]
+
+[case testNegativeIntLiteral]
+from typing_extensions import Literal
+
+a: Literal[-2] = -2
+b: Literal[-1] = -1
+c: Literal[0] = 0
+d: Literal[1] = 1
+e: Literal[2] = 2
+[out]
+[builtins fixtures/float.pyi]
+
+[case testNegativeIntLiteralWithFinal]
+from typing_extensions import Literal, Final
+
+ONE: Final = 1
+x: Literal[-1] = -ONE
+
+TWO: Final = 2
+THREE: Final = 3
+
+err_code = -TWO
+if bool():
+    err_code = -THREE
+[builtins fixtures/float.pyi]