Using class variables for literal values

110 views Asked by At

This is more of a request for comments and critique rather than a proper question.

Context: I am using simple classes with class variables as containers for string constants. These might hold ids of deployment environments, hostnames for specific apis or whatnot. Enum or StrEnum is not really fitting since I don't need all that functionality.

What I have always been missing is a way to use the values of such classes to annotate Literal values. I came up with a working solution but would like to know if there are other/better approaches to the problem at hand.

So, these string constant classes might look like this:

class MusicGenre:
    ROCK = "rock'n'roll"
    POP = "pop music"
    ELECTRONIC = "techno"

Now, this is all fine for 'standard' use-cases like if genre == MusicGenre.ROCK, but what if I want to use the values to indicate to static type checkers that I am expecting a literal? Of course, I could just type the hardcoded values. But what if the string constants change? I don't want to manually search through a huge codebase and update them all. Even less so if the constants are defined in a utility package that is used in various other packages.

So, this would be the hardcode-and-deal-with-it approach:

def get_random_song(genre: Literal["rock'n'roll", "pop music", "techno"):
    ...

Not very cool. What I came up after much searching around for solutions is a base class for string constant containers that can build literals. By building a tuple from all the constant's values in the class, I can instantiate a Literal containing said values:

from typing import LiteralString


class StringConstantContainer:

    @classmethod
    def as_literal(cls) -> LiteralString:
        return Literal[
            tuple(
                v
                for k, v in vars(cls).items()
                if not k.startswith("_") and not callable(getattr(cls, k))
            )
        ]

# use it:

class MusicGenre(StringConstantContainer):
    ROCK = "rock'n'roll"
    POP = "pop music"
    ELECTRONIC = "techno"

def get_random_song(genre: MusicGenre.as_literal()) -> str:
    if genre == MusicGenre.ROCK:
        return "Smells Like Teen Spirit"
    else:
        return "That's not music, my friend"

This does work, static type checkers don't complain and I am generally happy with it. It just seems kinda murky and I wonder if there's a better approach to this.

Update: An important aspect is that I make heavy use of pydantic. So if I were to use StrEnum or Enum, I would need to implement custom serializers in order to have dumped models show the expected str type, and not an Enum.*. Adding to above code:

from enum import Enum
from pydantic import BaseModel

class MusicGenreEnum(str, Enum):
    ROCK = "rock'n'roll"
    POP = "pop music"
    ELECTRONIC = "techno"
    
class Music(BaseModel):
    genre: MusicGenre.as_literal()
    enum_genre: MusicGenreEnum
    
Music(genre="techno", enum_genre="pop music").model_dump()
# {'genre': 'techno', 'enum_genre': <MusicGenreEnum.POP: 'pop music'>}
1

There are 1 answers

2
toyota Supra On

I came up with a working solution but would like to know if there are other/better approaches to the problem at hand.

Using enums is preferable over constants and programmatic access to their members.

Snippet:

from __future__ import annotations
import enum

class MusicGenre(enum.StrEnum):
    ROCK = "rock'n'roll"
    POP = "pop music"
    ELECTRONIC = "techno"
    
    @property
    def other(self) -> MusicGenre:
        return

Print in debug.

print(MusicGenre.ROCK)
print(MusicGenre['ROCK'])
print(MusicGenre("rock'n'roll").other)
print(MusicGenre('techno').name)
print(MusicGenre.ROCK.value)
print(MusicGenre.ROCK == 'ROCK')
print(isinstance(MusicGenre.ROCK, str))
print(MusicGenre.ROCK.upper())
for music_genre in MusicGenre:
    print(music_genre)

Output:

rock'n'roll
rock'n'roll
None
ELECTRONIC
rock'n'roll
False
True
ROCK'N'ROLL
rock'n'roll
pop music
techno