How to deal with groups of magic strings (to get autocomplete, type hints…)?

In many cases we have some group of valid strings and they end up becoming magic strings in the code difficult to find, change, maintain, type hint…
First step is usually to define constants for them.
But it becomes difficult to group them and get type annotations for functions that expect one string from a group.

If we could use attributes then it becomes much easier to group them, use and maintain them (we get autocomplete, can find references, etc.) but it is not obvious which python object is the right one for the job.

Enum is a good candidate, but it forces us to use the .value if we want/have to use the strings (and not the Enum type).

Literal seems to be the right for the job of limiting values in a function parameter, but I cannot figure a way to link it to the strings (without having to type the strings twice).

Example of what I’m trying to describe:

class Mode(???):
    # should this be Enum? dataclass? named tuple? constants grouped somehow? ...
    # The goal is to be able to later do something like Mode.read
    # It should ideally be immutable/frozen
    read = 'r'
    write = 'w'

# Could we refer to Mode to keep it DRY and easier to maintan whenever we add options?
AnyMode = Literal['r', 'w']

def f(mode: AnyMode)
    ...

f(Mode.read)

Using Enum comes close

from enum import Enum
from typing import Literal

class Mode(Enum):
    read = 'r'
    write = 'w'
    
def f(mode: Literal['r', 'w']):
    # Could be a third party function we cannot change.
    # If we can change it, then we would like to refer to Mode instead
    #  of having to type the same strings again.
    if mode in ('r', 'w'):
        print('mode is ok')
    else:
        print('mode is NOT ok')

# We are forced to use the .value all the time (not desired)
f(Mode.read.value)  # mode is ok
f(Mode.write.value)  # mode is not ok

# wrap f to avoid having to use .value all the time ?
def f_bis(mode: Mode):
    return f(mode.value)

# We don't need to use .value but we had to wrap the function
f_bis(Mode.read)  # mode is ok
f_bis(Mode.write)  # mode is ok

ModeLit = Literal[Mode.read, Mode.write] # this works but refers to Enum objects, not strings
ModeLit = Literal[Mode.read.value, Mode.write.value] # IDE complains about this

Using dataclasses? Feels like a lot of code and repetitions for something simple…

ReadModeType = Literal['r']
WriteModeType = Literal['w']
ValidModeTypes = Literal[ReadModeType, WriteModeType]

@dataclasses.dataclass(frozen=True, eq=True)
class _Mode:
    read: ReadModeType = "r"
    write: WriteModeType = "w"

Mode = _Mode() # have to instantiate it to have it frozen
ModeType = Literal[Mode.read, Mode.write] # Literal does not like this

def f(mode: ValidModeTypes):
    if mode in [Mode.read, Mode.write]:
        print('Mode is ok')
    else:
        print('Mode is not ok')

f('r') # type check ok
f('oops') # type check NOT ok
f(Mode.read) # type check ok
f(Mode.write) # type check ok

Enums can inherit from str:

class Mode(str, Enum):
    read = 'r'
    write = 'w'

assert Mode.read == 'r'

Thanks for the tip about the (str, Enum).
Is it possible to make the type hints work?

from enum import Enum
from typing import Literal

class Mode(str, Enum):
    read = 'r'
    write = 'w'

def f(m:Mode):
    ...

f(Mode.read) # ok
f('r')  # type checker complains

def g(m:Literal['r', 'w']):
    ...

g(Mode.read) # type checker complains
g('r')  # ok

Untested:

def g(m:Mode|Literal['r', 'w']):

Thanks, that works.
Is it possible to define the Literal in terms of the Enum such that when a new option is added to the Enum the literal also gets it?
This seems to work when I run it manually:

>>> t = Literal[tuple(Mode)]
>>> t
typing.Literal['r', 'w']

but mypy and the IDE complain.
I tried these variations unsuccessfully:
Literal[tuple(Mode)]
Literal[Mode]