From d8b6efeb050148fdcfdad29f824e0089367c6c63 Mon Sep 17 00:00:00 2001 From: Dmitry Vasilyanov Date: Wed, 10 Apr 2024 11:05:46 +0300 Subject: [PATCH] refactor: switch: sentinel_value -> enum (for better type checking) 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. --- .../context_var_descriptor.py | 53 +++++++++---------- contextvars_registry/context_vars_registry.py | 16 ++++-- docs/context_var_descriptor.rst | 2 +- docs/context_vars_registry.rst | 2 +- poetry.lock | 13 +---- pyproject.toml | 1 - 6 files changed, 42 insertions(+), 45 deletions(-) diff --git a/contextvars_registry/context_var_descriptor.py b/contextvars_registry/context_var_descriptor.py index ba338d0..5dd07f6 100644 --- a/contextvars_registry/context_var_descriptor.py +++ b/contextvars_registry/context_var_descriptor.py @@ -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 @@ -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:: @@ -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: @@ -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` @@ -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: @@ -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( @@ -830,7 +829,7 @@ def get_context_var_default( >>> get_context_var_default(timezone_var) - + You can also use a custom missing marker (instead of :data:`NO_DEFAULT`), like this:: diff --git a/contextvars_registry/context_vars_registry.py b/contextvars_registry/context_vars_registry.py index 3a8fe02..df38a2d 100644 --- a/contextvars_registry/context_vars_registry.py +++ b/contextvars_registry/context_vars_registry.py @@ -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, @@ -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]]: diff --git a/docs/context_var_descriptor.rst b/docs/context_var_descriptor.rst index 6281fec..1c2dd74 100644 --- a/docs/context_var_descriptor.rst +++ b/docs/context_var_descriptor.rst @@ -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() - + Performance Tips diff --git a/docs/context_vars_registry.rst b/docs/context_vars_registry.rst index 3af702f..5f86c5a 100644 --- a/docs/context_vars_registry.rst +++ b/docs/context_vars_registry.rst @@ -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() - + So, long story short: once a :class:`contextvars.ContextVar` object is allocated, it lives forever in the registry. diff --git a/poetry.lock b/poetry.lock index 62fee2f..f6804d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1368,17 +1368,6 @@ files = [ {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, ] -[[package]] -name = "sentinel-value" -version = "1.0.0" -description = "Sentinel Values - unique objects akin to None, True, False" -optional = false -python-versions = ">=3.6.0,<4.0.0" -files = [ - {file = "sentinel-value-1.0.0.tar.gz", hash = "sha256:2ff8e9e303c8f6abb2ad8c6d2615ed5f11061eeda2e51edfd560dc0567de633a"}, - {file = "sentinel_value-1.0.0-py3-none-any.whl", hash = "sha256:fab2501cb3f40c412a105b9a93089780c571468963f7bbcd0b5772ecdcfdc8cc"}, -] - [[package]] name = "setuptools" version = "69.2.0" @@ -1817,4 +1806,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.8.10" -content-hash = "1347616f8c1c8215e711cd075783e1819df95a4c4314f376e93cca6817f2f682" +content-hash = "487297c3dd0c073bb7d68067f842f7a5f2f100a4a2dd8efb6dd73b1bb0a0433d" diff --git a/pyproject.toml b/pyproject.toml index 4060418..ecac7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}