4

In my app, I am passing error reasons in my JSON API like this: {"ok": false, "reason": "EMAIL_ALREADY_REGISTERED"}. However, using plain strings like this is very vulnerable to errors like typing just "EMAIL_REGISTERED", or various typos.

So I thought about creating some util to only allow fixed set of values. My first idea was Enum:

from enum import Enum
class ErrorReason(Enum):
    EXCEPTION = 1
    EMAIL_ALREADY_REGISTERED = 2
    PASSWORD_TOO_SHORT = 3

This is great that IDE (PyCharm) automatically suggests me possible values when I type ErrorReason.E and checks if the given value is valid, however, it has drawbacks too:

  • there are unnecessary numeric values that I don't ever need
  • serializing this value when passing it around: str creates "ErrorReason.EXCEPTION", but I can also do ErrorReason.EXCEPTION.name, or .value (getting the numeric value), and flask.jsonify doesn't support it by default, so I need to set a JSON serializer subclass
  • it also feels to me that this isn't a correct/intended use of Enum

One other way to do this could be this:

class ErrorReason:
    EXCEPTION = "EXCEPTION"
    EMAIL_ALREADY_REGISTERED = "EMAIL_ALREADY_REGISTERED"
    PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT"

This looks a bit cleaner, and ErrorReason.EXCEPTION evaluates to simple string, but it feels wrong too – I have to write every possible value twice, and object with this sole purpose feels overkill to me.

What is the best way to achieve this? Or at least, what's the best way to create the "dumb" simple object in the last example without typing everything twice, while keeping smart IDE suggestions?


Edit 1: I found a way to generate the given class. However, even though I generate annotations, PyCharm still doesn't do any autocomplete suggesting.

_attrs = {"__annotations__": {}}
for reason in ("EXCEPTION", "PASSWORD_TOO_SHORT", "EMAIL_ALREADY_REGISTERED"):
    _attrs[reason] = reason
    _attrs["__annotations__"][reason] = str

ErrorReason = type("ErrorReason", (), _attrs)

See beginning of this answer for how are classes dynamically created.

Konrad Rudolph
  • 530,221
  • 131
  • 937
  • 1,214
M. Volf
  • 1,259
  • 11
  • 29
  • Can you explain why you think `class ErrorReason` is overkill and `class ErrorReason(Enum)` is not? – Wups Sep 28 '20 at 10:03
  • It's kinda included under the point "this is not the intended use of Enum". I guess the overkill isn't that big issue for the second example, but the fact that I have to type everything twice makes me think it can be done better – M. Volf Sep 28 '20 at 10:08
  • Naming constants is exactly what `Enum` is for. – Ethan Furman Sep 28 '20 at 13:51
  • @EthanFurman but are they for naming constants by the same name? Just to create strings from an allowed set? – M. Volf Sep 28 '20 at 13:53
  • 1
    @M.Volf: Yes. As you noted above, having a restricted set of choices is valuable, and `Enum` was designed to allow that restricted set to be integers, strings, etc., and to give them friendlier `repr()`s to aid in debugging. And yes, I'm sure -- I wrote it. ;-) – Ethan Furman Sep 28 '20 at 14:20

2 Answers2

2

You could use automatic enum values and change the behavior of auto() and __str__:

from enum import Enum, auto

class ErrorReason(str, Enum):

    def _generate_next_value_(name, start, count, last_values):
        return name

    def __str__(self):
        return self.name

    EXCEPTION = auto()
    EMAIL_ALREADY_REGISTERED = auto()
    PASSWORD_TOO_SHORT = auto()

Now: print(ErrorReason.EMAIL_ALREADY_REGISTERED) will be just EMAIL_ALREADY_REGISTERED

As stated in the answer by Tom Wojcik, also inherit from str, to make it serializable by json.

Wups
  • 2,489
  • 1
  • 6
  • 17
2

there are unnecessary numeric values that I don't ever need

You need them. It's literally what you return. I know what you mean by having a need for only one of name or value, but it's better to keep them the same. auto helps with that.

serializing this value when passing it around: str creates "ErrorReason.EXCEPTION", but I can also do ErrorReason.EXCEPTION.name, or .value (getting the numeric value), and flask.jsonify doesn't support it by default, so I need to set a JSON serializer subclass

Indeed, Enum is not serializable.

By default this will fail

from enum import Enum
import json


class ErrorReason(Enum):
    EXCEPTION = "EXCEPTION"
    EMAIL_ALREADY_REGISTERED = "EMAIL_ALREADY_REGISTERED"
    PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT"


print(json.dumps({"exc": ErrorReason.EXCEPTION}))

with

TypeError: Object of type ErrorReason is not JSON serializable

That's why it's encouraged to use it with str (look at parents).

from enum import Enum
import json


class ErrorReason(str, Enum):
    EXCEPTION = "EXCEPTION"
    EMAIL_ALREADY_REGISTERED = "EMAIL_ALREADY_REGISTERED"
    PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT"


print(json.dumps({"exc": ErrorReason.EXCEPTION}))

Will serialize just fine with str mixin.

Tom Wojcik
  • 5,471
  • 4
  • 32
  • 44
  • Ok, but is the Enum needed there at all then? That's basically the second solution i suggested in my question, and it has the issue that all the values are typed twice, which should be not necessary – M. Volf Sep 28 '20 at 11:27
  • 1
    Hard to say. It's the tool that does the job right. Plain class will achieve the same thing and I think it used to be a de facto standard in py2. It depends on what is it that you will do with it. If there are no downsides, I'd say it's better to use `Enum` because of nice `repr` and cool iteration. But it's up to you, just use any constants. It's just a tool. Does it really take you that much time to write it twice? Autocompletion from IDE is needed, as you said. – Tom Wojcik Sep 28 '20 at 11:57
  • 1
    One more thing. With enum you can add static typing `func(e: ErrorReason)` and without `Enum` it won't work as class attribute is not a type of class, while enum member is. – Tom Wojcik Sep 28 '20 at 12:02