12

For an enum Foo, How could one type hint a variable that must contain a member value (not the member itself) of some Enum-- e.g. a value such that Foo(x) will return a valid member of Foo?

Here's a simplified version of my motivating example:

class DisbursementType(Enum):
    DISBURSEMENT = "disbursement"
    REFUND = "refund"
    ROLLBACK = "rollback"

class SerializedDisbursement(TypedDict):
    transaction_type: ???
    id: str
    amount: float

a: SerializedDisbursement = {"transaction_type": "refund", id: 1, amount: 4400.24}

I would really like to avoid simply typeing transaction_type as Literal['disbursement', 'refund', 'rollback'] as that would be quite prone to getting out of synch over time.

Nentuaby
  • 590
  • 6
  • 10

2 Answers2

9

The most widely compatible option is to just have an assertion that validates that the literal type doesn't go out of sync with the enum values:

class DisbursementType(enum.Enum):
    DISBURSEMENT = "disbursement"
    REFUND = "refund"
    ROLLBACK = "rollback"

DisbursementValue = typing.Literal['disbursement', 'refund', 'rollback']

assert set(typing.get_args(DisbursementValue)) == {member.value for member in DisbursementType}

class SerializedDisbursement(typing.TypedDict):
    transaction_type: DisbursementValue
    id: str
    amount: float

This ensures maximum compatibility with static analyzers, but requires repeating all member values. Also, the assertion cannot be checked statically.


Other options break static analysis. For example, if you use the functional API to create the enum from the literal type:

DisbursementValue = typing.Literal['disbursement', 'refund', 'rollback']

DisbursementType = enum.Enum('DisbursementType',
                             {name.upper(): name for name in typing.get_args(DisbursementValue)})

then mypy doesn't understand the enum, and at that point, there's little point having annotations at all.

Similarly, if you try to use non-literal type arguments for the Literal type:

DisbursementValue = typing.Literal[tuple(member.value for member in DisbursementType)]

then that breaks too.

user2357112
  • 260,549
  • 28
  • 431
  • 505
  • That works! I can deal with the minor dis-ergonomic factor of hand-keying the values twice, but not the bug-promoting factor. With a simple helper to do the assertion, this takes me there. – Nentuaby Apr 28 '21 at 01:52
  • Mypy not understanding the Enum functional API is a major problem, it basically coerces you to declaring Enums with class syntax whenever possible. – bad_coder Apr 28 '21 at 03:07
  • @bad_coder: It understands the functional API, but only "static" uses of it - the second argument has to be a literal. It can't evaluate arbitrary runtime expressions to figure out the names and values. – user2357112 Apr 28 '21 at 03:12
2

While this doesn't answer the question as asked, I think you might also reconsider why you're using a TypedDict with a string instead of a proper class to hold the enum (instead of a str, since DisbursementType really does seem like an enum), and which can then employ some custom serialization logic.

For example:

import dataclasses as dc
import json
from enum import Enum


class Transaction(Enum):
    DISBURSEMENT = "disbursement"
    REFUND = "refund"
    ROLLBACK = "rollback"

    def __str__(self):
        return self.value


@dc.dataclass
class Disbursement:
    transaction: Transaction
    id_: str
    amount: float

    def __str__(self):
        return json.dumps(dc.asdict(self), default=str)


if __name__ == "__main__":
    disbursement = Disbursement(
        Transaction.REFUND,
        "1",
        4400.24,
    )
    print(disbursement)
$ mypy example.py
Success: no issues found in 1 source file
$ python3 example.py
{"transaction": "refund", "id_": "1", "amount": 4400.24}

Alternatively, you can have your enum inherit from str and simplify a few things:

import dataclasses as dc
import json
from enum import Enum


class Transaction(str, Enum):
    DISBURSEMENT = "disbursement"
    REFUND = "refund"
    ROLLBACK = "rollback"


@dc.dataclass
class Disbursement:
    transaction: Transaction
    id_: str
    amount: float

    def __str__(self):
        return json.dumps(dc.asdict(self))


if __name__ == "__main__":
    disbursement = Disbursement(
        Transaction.REFUND,
        "1",
        4400.24,
    )
    print(disbursement)

Other considerations:

Finally, I wanted to note that defining __str__ on my Enum did not do what I expected it to do using your TypedDict example above; that's because str(mydict) calls repr to provide each of mydict.values:

class Example:
    def __repr__(self):
        print("I called repr!")
        return "from repr"

    def __str__(self):
        print("I called str!")
        return "from str"


if __name__ == "__main__":
    print(f"example: {Example()}\n")

    d = {"example": Example()}
    print(f"in a dict: {d}")
$ python3 foo.py
I called str!
example: from str

I called repr!
in a dict: {'example': from repr}

Additionally, you can't add custom methods to a TypedDict; if you change Example to inherit from TypedDict and rerun that last example, you'll see that neither __repr__ nor __str__ is called, and unfortunately there is no runtime error either (mypy helpfully warns error: Invalid statement in TypedDict definition; expected "field_name: field_type"). Because serialization logic seems to belong to Disbursement, I changed it to a somewhat similar class that allows me to customize its __str__: a dataclass.

n8henrie
  • 2,737
  • 3
  • 29
  • 45
  • I'm afraid your suggestions are coming at the problem backward. The serialized disbursement comes off the wire from non-Python and passes through framework code which transforms it into a plain old dict. Certain portions of my logic are plugged into the framework and must interact with those rawer data, outside of my core business logic which can later enliven it into a full Disbursement object. My use of TypedDict is to apply some structure to that P.O.D. – Nentuaby Nov 11 '21 at 18:29
  • @Nentuaby Perhaps you could have explained your requirements better -- for example "`SerializedDisbursement` must be a dictionary, and this design decision is outside of my control." Is there any additional information you can provide about what aspects of this problem you can and cannot control? It would seem like you agree that the use of a dictionary here would be an inferior way to go about it, if you had the freedom to do otherwise. – n8henrie Nov 11 '21 at 22:06