Constants namespace with dataclass-like features

Sometimes I want class-like dataclass-like behavior on a collection of constants, so I do something like

@dataclass(frozen=True, init=False)
class _MyPaths:
    inputs: Path = Path("input/path")
    outputs: Path = Path("output/path")

MY_PATHS = _MyPaths()

Then MY_PATHS is the constant that I wanted, and has some nice properties:

  • I can use dataclasses.fields to iterate over the fields of the class.
  • Unlike a dict, I can access values cleanly with MY_PATHS.input_path with tab-completion in my editor instead of needing to guess at the dict key like MY_PATHS["input_path"].
  • Type checkers can understand and enforce all the type information involved.

But it would be even cleaner if I could just do something like

@constant
class MyPaths:
    inputs: Path = Path("input/path")
    outputs: Path = Path("output/path")

and have the result that MyPaths is a constant immutable object, allowing me to access MyPaths.inputs directly, and raising an error in case of any attempt to instantiate it or modify its attributes.

I’m not sure if a decorator is the right approach – a signature like class MyPaths(Constant) would be fine as well.

Related: Typing rules for SimpleNamespace - #3 by alippai

1 Like

In this scenario I would just use a module of constants? What’s the class-like behavior that you’re using here?

A module has all the benefits and is simpler to use. You get type-checking, tab completion…If you want to iterate over values, you could just define that as another constant (fields = [inputs, outputs, .... etc])

What am I missing?

A module of constants is a reasonable alternative. My main concerns with that approach:

  • It’s easy to add a new constant and then forget to add it to the corresponding fields = [... meta constant – ideally the relationship is programmatically enforced.
  • In cases where I have several related small groups of constants, the module-of-constants approach would involve creating several tiny modules, which can be a little harder to read/organize/document vs allowing them to all live in the same model, grouped instead under class definitions.
1 Like

You could programmatically make this in a few ways. Probably the easiest is:

fields = [x for x in globals() if not x.startswith("_")]

This requires the discipline that anything NOT meant to be part of fields has a leading underscore (eg if you import any other modules), but otherwise is completely automatic.

You can define that decorator yourself:

def constant(cls):
    return dataclass(frozen=True, init=False)(cls)()

Now this does just what you want:

@constant
class MyPaths:
    inputs: Path = Path("input/path")
    outputs: Path = Path("output/path")
8 Likes

Amazing – that looks good, thanks!

Just want to link @invoke built-in Decorator for Immediately Invoked Function (IIF) Ability, which was basically the same thing.

@operator.call came up, so you can also do:

@operator.call
@dataclass(frozen=True, init=False)
class MY_PATHS:
    inputs: Path = Path("input/path")
    outputs: Path = Path("output/path")

Which does the same thing as Ned’s decorator, if you’re not into the whole brevity thing.

2 Likes

Here is a constant decorator implementation that extends @nedbat’s solution. As shown in the demo,

  • type hints are now optional within the class declaration – sometimes the type is quite obvious both to the reader and the type checker, i.e. : Path = Path( is redundant.
  • __iter__ iterates over the name: value pairs, such that you can call dict directly on the class to get what you’d expect
1 Like

There’s also an option to use typing.NamedTuple, it is immutable:

from operator import call as const
from typing import NamedTuple

@const
class MyPaths(NamedTuple):
    inputs: Path = Path("input/path")
    outputs: Path = Path("output/path")
4 Likes