1

My function returns a tuple of two values, one of which is string with one of two possible values, say "a" or "b". This string is retrieved from the URL and indeed in theory can be something else - in which case I raise a Valuerror. However, it seems that mypy does not see my error, and still upset with string to Literal conversion. What is the proper way to solve that?

def _get_listing_info(txt: str) -> Tuple[int, Literal["a", "b"]]:
    
    findInfo = re.search(r"ios-app://\d+/item/(\w+)/(\d+)", txt)
    if findInfo is None:
        raise ValueError(f"Failed to establish listing type from `{txt}`")

    
    tp, listting_id = findInfo.group(1), int(findInfo.group(2))
    if tp not in {"a", "b"}:
        raise ValueError(tp, url)

    return listting_id, tp

Mypy raises an issue with:

Expression of type "tuple[int, str]" cannot be assigned to return type "Tuple[int, > Literal['a', 'b']]" Tuple entry 2 is incorrect type Type "str" cannot be assigned to type "Literal['a', 'b']" "str" cannot be assigned to type "Literal['a']" "str" cannot be assigned to type "Literal['b']"

Philipp_Kats
  • 3,872
  • 3
  • 27
  • 44

1 Answers1

1

The reason this doesn't work is because x is a Literal["a", "b"] only if x is equal to "a" or "b" and no other string (see Literal semantics here).

I could subclass str like this:

class CustomStr(str):
    def __eq__(self, other):
        return True

which is equal to everything, including "a". To verify this:

>>> "a" == CustomStr()
True

>>> CustomStr() == "a"
True

>>> "b" == CustomStr()
True

>>> CustomStr() == "b"
True

(so this str ends up being equal to both "a" and "b", regardless of whether CustomStr()'s __eq__ or the literal string's __eq__ gets called)

Checking whether this custom string equals "a" would not be enough to make it Literal["a"] because it is also equal to every other string, which violates Literal semantics.

In general, being able to subclass types and override their methods (which is completely type safe in mypy) makes any sort of narrowing based on their methods very hard to do. This applies to the __contains__ / in method of your example's {"a", "b"} set, and also the __eq__ / == of findInfo.

Hypothetically, your example could type check, if:

  1. re.search somehow indicated to mypy it returns an object whose type is exactly str, and not potentially a subclass

  2. because of your use of a set literal, mypy inferred that __contains__ returning True means that the argument to __contains__ is == with one of the elements in that set (which also assumes __hash__ is implemented correctly, again something that mypy cannot guarantee for an arbitrary subclass of str that re.search is type hinted to return).

However, mypy doesn't take advantage of either of these facts in your example.

To make your example actually type-check, you could change it to this:

def _get_listing_info(txt: str) -> Tuple[int, Literal["a", "b"]]:
    findInfo = re.search(r"ios-app://\d+/item/(\w+)/(\d+)", txt)
    if findInfo is None:
        raise ValueError(f"Failed to establish listing type from `{txt}`")

    tp, listting_id = findInfo.group(1), int(findInfo.group(2))

    if tp == "a":
        return listting_id, "a"

    if tp == "b":
        return listting_id, "b"

    raise ValueError(tp, "abc")

And it is 100% safe because we know that re.search cannot return a CustomStr that satisfies those ==s but ends up not being an "a".

Mario Ishac
  • 5,060
  • 3
  • 21
  • 52