#Future of Empty and EmptyType

1 messages ยท Page 1 of 1 (latest)

flint timber
#

We have lots of examples of using Empty to sentinel an unset value, e.g.:

https://github.com/litestar-org/litestar/blob/a2e5b7854edce5b40d23bc3493d47f579bdcddef/litestar/handlers/base.py#L184-L199

def signature_model(self) -> type[SignatureModel]:
    if self._signature_model is Empty:
        self._signature_model = SignatureModel.create(
            dependency_name_set=self.dependency_name_set,
            fn=cast("AnyCallable", self.fn),
            parsed_signature=self.parsed_fn_signature,
            data_dto=self.resolve_data_dto(),
            type_decoders=self.resolve_type_decoders(),
        )
    return cast("type[SignatureModel]", self._signature_model)

where self._signature_model: type[SignatureModel] | EmptyType = Empty

However the issue with this is that it is really difficult, if not impossible to get mypy to narrow the Empty away - hence why we still need the cast() in the example above.

I want to create a new sentinel for empty, which is valid to be used in a Literal so that it can be narrowed properly:

# types/empty.py
...

class EnumEmpty(Enum):
    """A sentinel class used as placeholder."""

    EMPTY = 0


TypeEmpty = Literal[EnumEmpty.EMPTY]
EMPTY: Final = EnumEmpty.EMPTY

My plan would be to replace all internal uses of Empty with EMPTY, so in the above example:

self._signature_model: type[SignatureModel] | TypeEmpty = EMPTY and we'd no longer need to cast() the return of all of these private attrs.

And removal of Empty from public interfaces would have to be a 3.0 thing..

Any thoughts? I know its not ideal that we'd have 4 different "empty things", i.e,. Empty, EMPTY, EmptyType, TypeEmpty (also, I don't like TypeEmpty as a name at all) but IMO its an OK price to pay as long as they are named and name-spaced properly (maybe even an alternate, private for now _empty module for the new ones).

strong halo
#

I'm curious as to why mypy is not able to figure this out.

#

pyright is able to tell that the return type is type[SignatureModel] due to the is Empty check.

flint timber
#

we type the thing as a union with type[Empty] and then perform a is identity check on the type of empty.. e.g., if self._cached is Empty

#

just because a thing isn't Empty doesn't mean it cannot be a sub-type of Empty

#

at least, that's what I think is going on

#

so what we want is put the thing in a union with Literal[type[Empty]], but that doesn't work

#

and I don't know if it would actually solve the problem if it was allowed

#

the above pattern does work - i read an issue where guido suggested it

#

I'll find it

signal acornBOT
#

This came up python/typeshed#3521: Currently I can't think of a way to type sentinel values that are often constructed by allowing a certain instance of object as the argument. For the example above it would be useful to be able to do something like this:

class PSS(AsymmetricPadding):
    MAX_LENGTH: ClassVar[object]
    def __init__(self, mgf: MGF1, salt_length: Union[int, Literal[MAX_LENGTH]]) -> None: ...

Alternatively we could add a type like Singleton type to typing:

class PSS(AsymmetricPadding):
    MAX_LENGTH: Singleton
    def __init__(self, mgf: MGF1, salt_length: Union[int, MAX_LENGTH]) -> None: ...

Labels

topic: feature

flint timber
#

interesting, and sort of related because I found out about it while experimenting with some solutions for Empty narrowing, did you know that typeguard doesn't narrow the negative case, only the positive? E.g.,:

def is_empty_type(val: Any) -> TypeGuard[type[Empty]]:
    return val is Empty

thing: int | EmptyType
if is_empty_type(thing):
    # thing is narrowed to `EmptyType` here
    ...
else:
    # thing is still `int | EmptyType` here
    ...
slate lake
#

@humble olive

#

but he ignored us and everyone and just replied to himself

#

as is the way of CPython core devs i think. ๐Ÿ˜›

humble olive
flint timber
#

@humble olive literally just shared those with me ๐Ÿ˜„

slate lake
#

๐Ÿ˜

flint timber
#

too slow

slate lake
#

nooo

humble olive
#

too slow indeed

slate lake
#

dont give him more fuel

slate lake
#

hehe

#

sry to clutter your real thread ๐Ÿ˜›

flint timber
#

I'll take a good laugh over a clean thread any day

humble olive
flint timber
#

You could say that!

strong halo
strong halo
flint timber
#

would changing what Empty and EmptyType is be too breaking?

#

The reason I ask is if I just change the empty module to be this instead:

#
from __future__ import annotations

__all__ = ("Empty", "EmptyType")

from enum import Enum
from typing import Literal


class _EmptyEnum(Enum):
    """A sentinel enum used as placeholder."""

    EMPTY = 0


EmptyType = Literal[_EmptyEnum.EMPTY]
Empty = _EmptyEnum.EMPTY
#

then there are only 15 test failures

#

14 of those are for a bug that this has exposed which should be fixed anyway

flint timber
#

if users are just doing normal things with Empty (i.e., identity tests) then this should only help them the way it helps us..

#

they can't be sub-classing Empty b/c the current type is marked @final

#

so the only thing I can think of that might make this breaking is if users are doing issubclass(thing, Empty)

slate lake
#

theres like no way to know :\

flint timber
#

well... there's one way to know ๐Ÿ˜„

#

but there's really no reason to even use issubclass() when empty is @final because it cannot be subclassed..

slate lake
flint timber
#

the reason I thought about the issubclass thing is that I tried to use it myself in one of the iterations of trying to make the current sentinel pattern work.. but it didn't help anyway

strong halo
#

Out of curiosity, which is the failing test that's not a bug?

flint timber
#

we were passing Empty to FieldDefinition.default_factory which is typed Callable | None

#

it was passing b/c Empty as a class is callable

strong halo
#

Like is that legitimate use case users might have or something?

strong halo
flint timber
#

i feel like we just said the same thing

signal acornBOT
#

tests/unit/test_response/test_base_response.py line 169

(Empty, MediaType.JSON, True),
flint timber
#

that test failed where we were testing that a handler that returned Empty would result in a 500 response

#

i don't really know why that test exists

strong halo
flint timber
#

to preserve the behavior

#

after this PR, returning Empty from a handler would otherwise work b/c it is an Enum which can be serialized

strong halo
#

Aah nice

#

I think that error message could be changed though. It might be interpreted as a response without a body is not allowed.

flint timber
#

I blame co-pilot

#
        if content is Empty:
            raise RuntimeError("The `Empty` sentinel cannot be used as response content")