2
\$\begingroup\$

See code from this question about the advantages of Enums.

def print_my_string(my_string: str, mode: Literal['l', 'u', 'c']):
    if mode == 'l':
        print(my_string.lower())
    elif mode == 'u':
        print(my_string.upper())
    elif mode == 'c':
        print(my_string.capitalize())
    else:
        raise ValueError("Unrecognised mode")

There is a single function which has multiple behaviors depending on mode flag that has been passed in. It is possible to communicate to users via the documentation, exception, and source code, that valid modes are 'l', 'u', and 'c'. But in this code these are essentially magic strings. Code like this is used without issue all over the place, see scipy least_squares. Nonetheless I'm trying to understand better or best practices.

One improvement would be to define constants within the module.

LOWER_MODE = 'l'
UPPER_MODE = 'u'
CAPITALIZE_MODE = 'c'

def print_my_string(my_string: str, mode: Literal['l', 'u', 'c']):
    if mode == LOWER_MODE :
        print(my_string.lower())
    elif mode == UPPER_MODE:
        print(my_string.upper())
    elif mode == CAPITALIZE_MODE:
        print(my_string.capitalize())
    else:
        raise ValueError("Unrecognised mode")

However, I often see Enums come up as a solution to this problem.

from enum import Enum

class StringMode(Enum):
    LOWER_MODE = 'l'
    UPPER_MODE = 'u'
    CAPITALIZE_MODE = 'c'

def print_my_string(my_string: str, mode: StringMode):
    if mode == StringMode.LOWER_MODE :
        print(my_string.lower())
    elif mode == StringMode.UPPER_MODE:
        print(my_string.upper())
    elif mode == StringMode.CAPITALIZE_MODE:
        print(my_string.capitalize())
    else:
        raise ValueError("Unrecognised mode")

Somehow this seems nice, helpful for ides/code completion, etc. But there is one major downside it has for me (and which has me scratching my head when trying to use Enums to replace magic strings. If print_my_string is a public facing method then the user can't use this method without ALSO importing, understanding, and using the StringMode enum. I don't want to burden the user with this. I want to maintain the non-magic enum handling of options on the back end, but allow the users to continue to pass regular documented strings.

checks like 'l' == StringMode.LOWER do not work. Instead I have to do 'l' == StringMode.LOWER.value. This isn't great because if I'm using print_my_string internally I now can't use mode == StringMode.LOWER unless I check against BOTH StringMode.LOWER and StringMode.LOWER.value which just complicates things.

Is there a nice way to handle this that avoids using magic strings on the backend by using Enums, but allows users to pass in simple string literals on the front-end?

Right now the second method I showed using hard coded constants is feeling more attractive. But it somehow feels like what I'm doing here is almost exactly what enums are meant for, I just can't see quite how to make it work.

\$\endgroup\$
3
  • 2
    \$\begingroup\$ the user can't use this method without ALSO importing, understanding, and using the StringMode enum. - this is a feature, not a bug. \$\endgroup\$
    – Reinderien
    May 29, 2023 at 22:10
  • \$\begingroup\$ what makes the function better than just calling the kind of conversion the caller wants by themselves? ie. print(str.lower()). only reason if mode is dynamic ie. coming from stdin but then its a string anyway... \$\endgroup\$
    – slepic
    May 30, 2023 at 3:42
  • \$\begingroup\$ How about using a dict to convert the strings into callables without fifteen lines of if/elif/else? d = {'u': str.upper, 'l': str.lower, 'c': str.capitalize}; fun = d[mode]; print(fun(my_string)) \$\endgroup\$
    – Stef
    May 31, 2023 at 17:19

4 Answers 4

3
\$\begingroup\$

Use an enum mixed with the str type -- this allows your users to use either the string literal, or the enum member, and allows you to do direct comparisons in your code:

class StringMode(str, Enum):   # or StrEnum in Python 3.11+
    LOWER = 'l'
    UPPER = 'u'
    CAPITALIZE = 'c'

def print_my_string(my_string, mode):
    if mode == 'l':
        print(my_string.lower())
    elif mode == 'u':
        print(my_string.upper())
    elif mode == 'c':
        print(my_string.capitalize())
    else:
        raise ValueError("Unrecognised mode")

This allows for backwards-compatibility, and forward progress. As an old user of your code, I don't need to change a thing; as a new user, I would do something like:

from jager import print_my_string, StringMode as SM

print_my_string('See how easy?', SM.LOWER)
\$\endgroup\$
5
  • \$\begingroup\$ YES! This is what I needed! The ability for the enum member itself to be treated as a string for comparison purposes! One question: You speak about new users using the enum (rather than string literal) as "forward progress". But I guess I still find it burdensome and would encourage users to use the string literal because they have to import less and its simpler. This is how string-selectable "modes" are handled in many libraries like numpy or scipy. Is it really a bad practice that should be avoided? \$\endgroup\$
    – Jagerber48
    May 30, 2023 at 22:16
  • \$\begingroup\$ The key here is that StringMode.LOWER == 'l' is True. \$\endgroup\$
    – Jagerber48
    May 30, 2023 at 22:25
  • 1
    \$\begingroup\$ @Jagerber48. import is not a burden, and users already have to import either the whole module or pieces such as print_my_string. One of the motivating cases for finally having a built-in enum was being able to replace magic numbers such as 42 and magic strings such as 'l' with names that are much more self-documenting. numpy and scipy are much older than enum, so they use magic constants, but I certainly wouldn't encourage that in new code. \$\endgroup\$ May 30, 2023 at 23:23
  • \$\begingroup\$ Thank you. One last question then. Could you point me to an example package(s) which uses this pattern (user imports “options” enum)? Perhaps I’m surprised by it since I just haven’t come across it yet in any packages I’ve personally used. \$\endgroup\$
    – Jagerber48
    May 31, 2023 at 13:58
  • \$\begingroup\$ @Jagerber48: My work is mostly on the stdlib, my own packages, and work-related code -- I have very little experience with other packages/libraries. For an example in the stdlib check the re module and the RegexFlag enum. \$\endgroup\$ May 31, 2023 at 19:12
3
\$\begingroup\$

Use built-ins as you are now; this does not call for a third-party library.

Type hinting matters, and I think you intuitively smell this because you're concerned about well-defined interfaces.

Your current implementation is fine (though it needs a -> None). The only function signatures I would accept are yours, or (probably more preferably) one with an enum. They would both pass type checks, but enums are more feature-rich: they have better self documentation, better potential for reflection and are more explicit.

I demonstrate three flavours. The first is basically yours, but uses a match.

The second uses an enum, and demonstrates that you can do membership checks on the enum's value against the class as a collection, and checks on its type as well (something you cannot do in runtime with str). Having both is redundant but kept for illustrative purposes.

The third is what I would prefer: it's simple, it throws an easy-to-understand exception if you pass it nonsense, and it's explicit.

import enum
from enum import Enum
from typing import Literal


def print_my_string(my_string: str, mode: Literal['l', 'u', 'c']) -> None:
    match mode:
        case 'l':
            print(my_string.lower())
        case 'u':
            print(my_string.upper())
        case 'c':
            print(my_string.capitalize())
        case _:
            raise ValueError(f"Unrecognised mode '{mode}'")


@enum.unique
class PrintMode(Enum):
    LOWER = 'l'
    UPPER = 'u'
    CAPITAL = 'c'


def print_my_string_enum(my_string: str, mode: PrintMode) -> None:
    if not isinstance(mode, PrintMode):
        raise TypeError()
    if mode not in PrintMode:
        raise ValueError(f"Unrecognised mode '{mode}'")
    match mode:
        case PrintMode.LOWER:
            print(my_string.lower())
        case PrintMode.UPPER:
            print(my_string.upper())
        case PrintMode.CAPITAL:
            print(my_string.capitalize())


STR_TRANSFORMS = {
    PrintMode.LOWER: str.lower,
    PrintMode.UPPER: str.upper,
    PrintMode.CAPITAL: str.capitalize,
}


def print_my_string_dict(my_string: str, mode: PrintMode) -> None:
    print(STR_TRANSFORMS[mode](my_string))


def test() -> None:
    print_my_string('foo', 'l')
    print_my_string_enum('foo', PrintMode.UPPER)
    print_my_string_dict('foo', PrintMode.CAPITAL)

    try:
        print_my_string('foo', 'x')
        raise AssertionError()
    except ValueError:
        pass

    try:
        print_my_string_enum('foo', 17)
        raise AssertionError()
    except TypeError:
        pass

    try:
        print_my_string_dict('foo', 'bar')
        raise AssertionError()
    except KeyError:
        pass


if __name__ == '__main__':
    test()
\$\endgroup\$
4
  • \$\begingroup\$ Thanks for this answer. Your last approach won't suffice for me because the actual use case is not as simple as calling simple functions based on the mode input. Regarding your second option: Can you say anything about my requirement that the user need not import the options Enum? Otherwise, I think I'm leaning towards something like the first approach. \$\endgroup\$
    – Jagerber48
    May 30, 2023 at 2:21
  • \$\begingroup\$ For the first example, can't you use mode: PrintMode | Literal['l', 'u', 'c'] in the parameter list and then case: PrintMode.Lower | 'l':, etc. in the match statement? \$\endgroup\$
    – RootTwo
    May 30, 2023 at 2:38
  • 1
    \$\begingroup\$ @Jagerber48 if this use case is not your actual use case then this question is off-topic because we expressly do not support reviewing placeholder or theoretical code \$\endgroup\$
    – Reinderien
    May 30, 2023 at 2:38
  • \$\begingroup\$ @Reinderien ah, shoot. I'm sorry. Came here on a suggestion that this sort of question would be better suited for this site than StackOverflow, but the question there was using this placeholder code. I'll see if I re-ask the question using actual code, but it may be quite long.. \$\endgroup\$
    – Jagerber48
    May 30, 2023 at 3:28
2
\$\begingroup\$

I dare to ask the question why you want to limit the user to the three given options. Why not make it more flexible for the user and allow them to specify the formatter themselves.

def print_my_string(my_string: str, formatter: Callable[[str], str]) -> None:
    print(formatter(my_string))

Then the caller can decide how they want to format their string:

print_my_string('FooBar', str.lower)
print_my_string('FooBar', str.upper)
print_my_string('FooBar', str.capitalize)
print_my_string('FooBar', str.casefold)

And since the function is pretty trivial, I'd argue that it can be refactored to deletion.

\$\endgroup\$
0
\$\begingroup\$

In the OP I gave a requirement for:

I want to maintain the non-magic enum handling of options on the back end, but allow the users to continue to pass regular documented strings.

One way to solve this is with a forward facing function with my desired API which calls a backend function which uses the enum machinery. I show this approach next to an approach using defined constants.

from typing import Literal, get_args
from enum import Enum


class StringMode(Enum):
    LOWER = 'lower'
    UPPER = 'upper'
    CAPITALIZE = 'capitalize'


def _print_my_string(my_string: str, mode: StringMode) -> None:
    if mode == StringMode.LOWER:
        print(my_string.lower())
    elif mode == StringMode.UPPER:
        print(my_string.upper())
    elif mode == StringMode.CAPITALIZE:
        print(my_string.capitalize())
    else:
        raise ValueError(f"Unrecognised mode {mode}")


UserStringModes = Literal['l', 'u', 'c']

mode_conversions = {'l': StringMode.LOWER,
                    'u': StringMode.UPPER,
                    'c': StringMode.CAPITALIZE}


def print_my_string_enum(my_string: str, mode: UserStringModes) -> None:
    if mode not in mode_conversions:
        raise ValueError(f"mode '{mode}' not in "
                         f"{tuple(mode_conversions.keys())}")
    _mode = mode_conversions[mode]

    _print_my_string(my_string, _mode)


LOWER = 'l'
UPPER = 'u'
CAPITALIZE = 'c'


def print_my_string_const(my_string: str, mode: UserStringModes) -> None:
    if mode == LOWER:
        print(my_string.lower())
    elif mode == UPPER:
        print(my_string.upper())
    elif mode == CAPITALIZE:
        print(my_string.capitalize())
    else:
        raise ValueError(f"mode '{mode}' not in "
                         f"{tuple(get_args(UserStringModes))}")

For this use case, where I want users to have the convenience of passing a string literal that they look up in the docs, I don't really see what is gained by using the Enum over just using the constants. It just seems like a lot of extra complication. I guess in the "constant" version of the code I require an implicit agreement between the list of available constants (LOWER, UPPER, CAPITALIZED) and the literal type hint for the function input.

Test slightly borrowed from Reinderien's answer:

def test() -> None:
    print_my_string_enum('foo', 'l')
    print_my_string_enum('foo', 'u')
    print_my_string_enum('foo', 'c')

    try:
        print_my_string_enum('foo', 'x')
        raise AssertionError()
    except ValueError as e:
        print(f"caught '{e}'")

    print_my_string_const('foo', 'l')
    print_my_string_const('foo', 'u')
    print_my_string_const('foo', 'c')

    try:
        print_my_string_const('foo', 'x')
        raise AssertionError()
    except ValueError as e:
        print(f"caught '{e}'")


if __name__ == '__main__':
    test()
>>> foo
>>> FOO
>>> Foo
>>> caught 'mode 'x' not in ('l', 'u', 'c')'
>>> foo
>>> FOO
>>> Foo
>>> caught 'mode 'x' not in ('l', 'u', 'c')'

Discussion: I guess the big question driving my original question is: How to get the available options into the users hands?

  • The naive way is to just use magic strings, and the dev and user agree what strings should be passed in by using the documentation.
  • Using the function which accepts an Enum as input directly relies on the users IMPORTING the available options, so there can be no mistaking the options. Unfortunately this feels like polluting the user's code's namespace.
  • The two approaches in this post handle modes within the source module using enums which is good. But, the only way they get the options "into the users' hands" is via the documentation PLUS the type hint. At this point, I think the type hint seems sufficient.
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.