Skip to content

Commit

Permalink
refactor: switch: sentinel_value -> enum (for better type checking)
Browse files Browse the repository at this point in the history
Problem: sentinel_value.SentinelValue doesn't work with Literal
and `is` (instead of `isinstance()`) way of checking.

Solution: switch to `Enum`, which has special-case support in `mypy`,
which improves type checking and allows to avoid `isinstance()`
checks in some cases.
  • Loading branch information
vdmit11 committed Apr 10, 2024
1 parent 33eba5a commit d8b6efe
Show file tree
Hide file tree
Showing 6 changed files with 42 additions and 45 deletions.
53 changes: 26 additions & 27 deletions contextvars_registry/context_var_descriptor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""ContextVarDescriptor - extension for the built-in ContextVar that behaves like @property."""

from enum import Enum

from contextvars import ContextVar, Token
from typing import Any, Callable, Generic, Optional, Type, TypeVar, Union, overload

from sentinel_value import SentinelValue

from contextvars_registry.context_management import bind_to_empty_context
from contextvars_registry.internal_utils import ExceptionDocstringMixin

Expand All @@ -21,24 +21,17 @@
_OwnerT = TypeVar("_OwnerT")


class NoDefault(SentinelValue):
class NoDefault(Enum):
"""Special sentinel object that means: "default value is not set".
Problem: a context variable may have ``default = None``.
But, if ``None`` is a valid default value, then how do we represent "no default is set" state?
So this :class:`NoDefault` class is the solution. It has only 1 global instance:
- :data:`contextvars_registry.context_var_descriptor.NO_DEFAULT`
this special :data:`NO_DEFAULT` object may appear in a number of places:
This special :data:`NO_DEFAULT` object may appear in a number of places:
- :attr:`ContextVarDescriptor.default`
- :meth:`ContextVarDescriptor.get`
- :func:`get_context_var_default`
- and some other places
and in all these places it means that "default value is not set"
where it indicates the "default value is not set" case
(which is different from ``default = None``).
Example usage::
Expand All @@ -49,30 +42,30 @@ class NoDefault(SentinelValue):
timezone_var has no default value
"""

NO_DEFAULT = "NO_DEFAULT"

NO_DEFAULT = NoDefault(__name__, "NO_DEFAULT")

NO_DEFAULT = NoDefault.NO_DEFAULT
"""Special sentinel object that means "default value is not set"
see docs for: :class:`NoDefault`
"""


class DeletionMark(SentinelValue):
"""Special sentinel object written into ContextVar when it has no value.
class DeletionMark(Enum):
"""Special sentinel object written into ContextVar when it is erased.
Problem: in Python, it is not possible to erase a :class:`~contextvars.ContextVar` object.
Once the variable is set, it cannot be unset.
But, we (or at least I, the author) need to implement the deletion feature.
Once a variable is set, it cannot be unset.
But, we still want to have the deletion feature.
So, the solution is:
1. Write a special deletion mark into the context variable.
2. When reading the variable, detect the deltion mark and act as if there was no value
1. When the value is deleted, write an instance of :class:`DeletionMark`
into the context variable.
2. When reading the variable, detect the deletion mark and act as if there was no value
(this logic is implemented by the :meth:`~ContextVarDescriptor.get` method).
So, an instance of :class:`DeletionMark` is that special object written
to the context variable when it is erased.
But, a litlle trick is that there are 2 slightly different ways to erase the variable,
so :class:`DeletionMark` has exactly 2 instances:
Expand Down Expand Up @@ -105,14 +98,17 @@ class DeletionMark(SentinelValue):
Just use the :meth:`ContextVarDescriptor.get` method, that will handle it for you.
"""

DELETED = "DELETED"
RESET_TO_DEFAULT = "RESET_TO_DEFAULT"


DELETED = DeletionMark(__name__, "DELETED")
DELETED = DeletionMark.DELETED
"""Special object, written to ContextVar when its value is deleted.
see docs in: :class:`DeletionMark`.
"""

RESET_TO_DEFAULT = DeletionMark(__name__, "RESET_TO_DEFAULT")
RESET_TO_DEFAULT = DeletionMark.RESET_TO_DEFAULT
"""Special object, written to ContextVar when it is reset to default.
see docs in: :class:`DeletionMark`
Expand Down Expand Up @@ -622,7 +618,6 @@ def set_if_not_set(self, value: _VarValueT) -> _VarValueT:
self.set(value)
return value

assert not isinstance(existing_value, SentinelValue)
return existing_value

def reset(self, token: "Token[_VarValueT]") -> None:
Expand Down Expand Up @@ -771,7 +766,11 @@ def __delete__(self, owner_instance: "Type[ContextVarDescriptor[_VarValueT]]") -


# A special sentinel object, used internally by methods like .is_set() and .set_if_not_set()
_NOT_SET = SentinelValue(__name__, "_NOT_SET")
class _NotSet(Enum):
NOT_SET = "NOT_SET"


_NOT_SET = _NotSet.NOT_SET


def _new_context_var(
Expand Down Expand Up @@ -830,7 +829,7 @@ def get_context_var_default(
<Token ...>
>>> get_context_var_default(timezone_var)
<NO_DEFAULT>
<NoDefault.NO_DEFAULT: 'NO_DEFAULT'>
You can also use a custom missing marker (instead of :data:`NO_DEFAULT`), like this::
Expand Down
16 changes: 13 additions & 3 deletions contextvars_registry/context_vars_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from contextvars import ContextVar, Token
from types import FunctionType, MethodType
from typing import Any, ClassVar, Dict, Iterable, Iterator, MutableMapping, Tuple, get_type_hints
from enum import Enum

from sentinel_value import sentinel

from contextvars_registry.context_var_descriptor import (
ContextVarDescriptor,
Expand Down Expand Up @@ -318,8 +318,18 @@ def __delitem__(self, key):
ctx_var.delete()


_NO_ATTR_VALUE = sentinel("_NO_VALUE")
_NO_TYPE_HINT = sentinel("_NO_TYPE_HINT")
class _NoAttrValue(Enum):
NO_ATTR_VALUE = "NO_ATTR_VALUE"


_NO_ATTR_VALUE = _NoAttrValue.NO_ATTR_VALUE


class _NoTypeHint(Enum):
NO_TYPE_HINT = "NO_TYPE_HINT"


_NO_TYPE_HINT = _NoTypeHint.NO_TYPE_HINT


def _get_attr_type_hints_and_values(cls: object) -> Iterable[Tuple[str, Any, Any]]:
Expand Down
2 changes: 1 addition & 1 deletion docs/context_var_descriptor.rst
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ If you want to reset variable to the default value, then you can use :meth:`~Con
or call some performance-optimized methods, like :meth:`~ContextVarDescriptor.get_raw`::

>>> timezone_var.get_raw()
<DELETED>
<DeletionMark.DELETED: 'DELETED'>


Performance Tips
Expand Down
2 changes: 1 addition & 1 deletion docs/context_vars_registry.rst
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ is when you use some low-level stuff, like :func:`save_context_vars_registry`, o
the :meth:`~.ContextVarDescriptor.get_raw` method::

>>> CurrentVars.user_id.get_raw()
<DELETED>
<DeletionMark.DELETED: 'DELETED'>

So, long story short: once a :class:`contextvars.ContextVar` object is allocated,
it lives forever in the registry.
Expand Down
13 changes: 1 addition & 12 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ classifiers = [

[tool.poetry.dependencies]
python = "^3.8.10"
sentinel-value = "^1.0.0"

[tool.poetry.group.dev.dependencies]
Flask = {extras = ["async"], version = "^3.0.2"}
Expand Down

0 comments on commit d8b6efe

Please sign in to comment.