Sentinal with attributes

I’ve got a situation, and the ‘obvious’[1] solution would be a Sentinal class with attributes.

Context:

We’ve got a code system where depending on the settings, some parts of the code might or might not run. So for example, we have

class Settings():
  coloc: ColocationSettings | None

and code that requires the value of settings.coloc.forecast only makes sense to run when colocation is enabled, so such code blocks are behind if settings.coloc is not None: guards. Which both makes sense and keeps the type-checker happy.

Now the issue is an array of SettingsOverrides that is read from a collection of csv files.
We define loosely speaking

period_settings[t] = combine(settings, settings_overrides[t])

and it is an invariant in our system that period_settings[t].coloc is None if and only if settings.coloc is None.
But accessing period_settings[t].coloc.forecast makes the type-checker angry, even if I do it inside a if settings.coloc is not None: guard.

What I want to do

The solution I have in mind is to create a sentinel, and type PeriodSettings as

class PeriodSettings():
  coloc: ColocationSettings | NULL

where NULL will have (for example) the forecast attribute, at least as far as the type checker is concerned. (If that attribute were accessed at runtime, I’m quite happy for it to raise an error.)

This would replace a significant number of cast() and assert statements.

The best I can come up with myself

@dataclass
class ColocationSettings:
    forecast: str
    max_export: float

class ColocNULL:
    # No init, fields remain uninitialised
    forecast: str
    max_export: float

COLOC_NULL = ColocNULL()

@dataclass
class PeriodSettings:
    coloc: ColocationSettings | ColocNULL

I think that gets me most of what I want and probably everything I need. But the PEP 661 Sentinels do have some nice properties that I’d like.


  1. to me ↩︎

Assuming settings.coloc is not None always means period_settings[t].coloc is not None. The type checker does not know of this invariant which is why one ends up requiring asserts everywhere.

Why is settings.coloc checked when period_settings[t].coloc is used? If settings.coloc enables/disables this feature then period_settings[t] = combine(settings, settings_overrides[t]) should respect that by returning the appropriate values, then only period_settings[t].coloc needs to be tested and you would not need to change anything else.

Alternatively, add the enabled state to ColocationSettings itself. Either by adding an enabled attribute or by assigning defaults for a disabled state:

@dataclass
class ColocationSettings:
    forecast: str
    max_export: float

    def __bool__(self) -> bool:
        """Return True if feature is enabled."""
        return self != COLOC_NULL

COLOC_NULL = ColocationSettings(forecast="", max_export=0)

...

if settings.coloc:
    ...

This would work, but it might hide any issues you are having with your combine function. I’m not confident that you’ve given enough context for your problem.

In short, there is no one unique period_settings[t].coloc to check, but there is one unique settings.coloc.

We end up using attributes stored on both objects, because some settings are overridable, and some are plain constants, so for the sake of explicitness (and also efficiency) we use settings.coloc for the constants and period_settings[t].coloc for the things that can be overridden.
But if we have to loop over a range of t, there’s a whole sequence of period_settings[t].coloc for all of which we have to either assert or cast. If we only cast the first of the sequence, the type checker still gets sad :wink:
That’s where things get tedious & ugly.

I’m not getting what you mean by this. But your code snippet suggest ‘just’ using an instance of ColocationSettings with specific attribute values as a Sentinel, which doesn’t seem like it would result in a better debugging experience than using a dedicated sentinel class :confused:

If settings.coloc acts as a enabler switch and period_settings[t].coloc does not then why are these the same type? Why not add a separate coloc_enabled: bool attribute to settings?

coloc: ColocationSettings | None may have been a architectural mistake which resulted in the rest of these issues. If settings.coloc is supposed to hold constants or defaults then it can’t be None, otherwise there would be someplace else to hold those constants.

It’s still unclear what this means. This does not explain why period_settings is a collection. Perhaps if t was more verbosely named or explained.

The type checker complains for a clear reason: the collection period_settings is defined as containing items of mixed types. It’s that way because combine returns mixed types into the collection. If the collection shouldn’t have mixed types then it should be defined as such and the remaining code should be refactored to respect that. period_settings[t] could then be used with no additional checks.

My solution was not ideal but the code would remain valid even if the values were unset compared to creating a class which defines but does not initialize its attributes.

I’d rather suggest a function similar to combine which only returns a valid final result. If you’re working with defaults and overrides then what actually matters is fetching the final result.

Needs a minimal reproducible example + info about which type checker we are talking about. 90% sure “sentinel” is a red herring.