Using the strings in a `Literal` at runtime

Suppose I have a function that takes a string parameter that’s supposed to be one of a limited set of strings, and I want a type checker to catch it if the caller passes something else. This is what typing.Literal is for…

from typing import Literal
ASPECT_TABLE_TYPE = Literal["aspect", "aspect2", "boresight", "metadata"]

def get_aspect_table(table: ASPECT_TABLE_TYPE) -> ...:
    # retrieve the requested table

But I also need a runtime check, because maybe the caller of my library didn’t bother running a type checker. So I’d like to write something like

def get_aspect_table(table: ASPECT_TABLE_TYPE) -> ...:
    if table not in ASPECT_TABLE_TYPE:
        raise ValueError(f"unrecognized table {table}")
    # retrieve the requested table

And here’s the actual question: I can’t find any way of writing if table not in ASPECT_TABLE_TYPE that both the main Python interpreter (>=3.10, if it matters) and type checkers are happy with.

  • The typing documentation does not state any official way to do this.
  • if table not in ASPECT_TABLE_TYPE provokes TypeError: typing.Literal[...] is not a generic class from the main interpreter.
  • if table not in ASPECT_TABLE_TYPE.__args__ works in the main interpreter but provokes error: "<typing special form>" has no attribute "__args__" [attr-defined] from mypy.
  • If I try to pull the arguments of Literal out to a separate variable,
    ALL_ASPECT_TYPES = ("aspect", "aspect2", "boresight", "metadata")
    ASPECT_TABLE_TYPE = Literal[ALL_ASPECT_TYPES]
    
    this also works in the main interpreter but mypy doesn’t like it, saying “Parameter 1 of Literal[…] is invalid”.

Am I just stuck repeating the list of valid strings at least twice?

1 Like

Can you just bypass this with a # type: ignore comment?

It’s not the most beautiful combination of hacks but it should do what you want, I think.

Huh, that does work. I am surprised that that error can be bypassed with a type:ignore comment.

I’m going to leave the question unresolved for the time being, hoping someone has a less hacky solution.

one of a limited set of strings,

The code might need a few tweeks, but this sounds an awful lot like a job for a StrEnum

1 Like

You could use from typing_inspect import get_args,
or do what this does inside which is to ask first hasattr(tp, '__args__') before res = tp.__args__,
or getattr(ASPECT_TABLE_TYPE, '__args__')

1 Like

typing.get_args does exactly what you want.

>>> from typing import get_args, Literal
>>> MyLiterals = Literal["a", "b", "c"]
>>> get_args(MyLiterals)
('a', 'b', 'c')

Note that the above doesn’t work for PEP 695 type aliases, you can read about that here: typing.get_args behaviour when using the new 3.12 PEP 695 feature (Type Parameter Syntax) · Issue #112472 · python/cpython · GitHub

3 Likes

IIRC the inspect.get_args approach doesn’t work for unions of literals either. It gets the arguments to the union which is the literal types, not the literal values.

I answered a similar question on stack overflow which I’ve used for PEP695 type aliases but I think might work for old aliases too:

from collections.abc import Sequence, Iterator
from typing import Literal, get_args, TypeAliasType, cast

def get_literal_vals(alias: TypeAliasType) -> frozenset:
    def val(alias: TypeAliasType):
        return alias.__value__
    def args(alias: TypeAliasType):
        return get_args(val(alias))
    def resolve[T](alias: TypeAliasType | tuple[T, ...] | T) -> Iterator[T]:
        if isinstance(alias, TypeAliasType):
            for val in resolve(args(alias)):
                yield from resolve(val)
            return
        if isinstance(alias, tuple):
            t_seq = cast(Sequence[T], alias)
            for element in t_seq:
                yield from resolve(element)
            return
        yield alias
    return frozenset(resolve(alias))

type Doubles = Literal["ab", "de", "gh"]
type Triples = Literal["abc", "def", "ghi"]
type DT = Doubles | Triples
dt_set: frozenset[DT] = get_literal_vals(DT)

Which is quite nasty but working in practice

>>> type Triples = Literal["abc", "def", "ghi"]
>>> get_literal_vals(Triples)
frozenset({'def', 'abc', 'ghi'})
>>> type Doubles = Literal["ab", "de", "gh"]
>>> get_literal_vals(Doubles)
frozenset({'gh', 'de', 'ab'})
>>> type DT = Doubles | Triples
>>> get_literal_vals(DT)
frozenset({'ghi', 'de', 'ab', 'abc', 'gh', 'def'})

It’d be nice if there was a standard way to get these values.

1 Like

Alas, only almost what I want; get_args’ own return type is tuple[Any, ...], so mypy needs to be told what Any is in this case. I now have

ASPECT_TABLE_TYPE = Literal["aspect", "aspect2", "boresight", "metadata"]
ALL_ASPECT_TABLES = \
    cast(list[ASPECT_TABLE_TYPE], sorted(get_args(ASPECT_TABLE_TYPE)))

Not pretty but better than a type:ignore comment.

Note that the above doesn’t work for PEP 695 type aliases

This package supports Python >=3.10 so I won’t be using those anytime soon :wink: I appreciate the warning anyway.


I’m going to look into that but it’ll be a much bigger change than I want to tackle right now.