Edit on GitHub

hexdoc.core.compat

  1from __future__ import annotations
  2
  3from dataclasses import dataclass, field
  4from typing import Annotated, Any, ClassVar, Generic, Protocol, TypeVar
  5
  6from packaging.specifiers import SpecifierSet
  7from pydantic import GetCoreSchemaHandler, ValidationInfo
  8from pydantic_core import core_schema
  9from typing_extensions import override
 10
 11from hexdoc.model.base import HexdocModel
 12
 13_T = TypeVar("_T")
 14
 15_T_ModelType = TypeVar("_T_ModelType", bound=type[HexdocModel])
 16
 17_If = TypeVar("_If")
 18_Else = TypeVar("_Else")
 19
 20
 21class VersionSource(Protocol):
 22    @classmethod
 23    def get(cls) -> str | None:
 24        """Returns the current version."""
 25        ...
 26
 27    @classmethod
 28    def matches(cls, specifier: str | SpecifierSet) -> bool:
 29        """Returns True if the current version matches the version_spec."""
 30        ...
 31
 32
 33class MinecraftVersion(VersionSource):
 34    MINECRAFT_VERSION: ClassVar[str | None] = None
 35
 36    @override
 37    @classmethod
 38    def get(cls) -> str | None:
 39        return cls.MINECRAFT_VERSION
 40
 41    @override
 42    @classmethod
 43    def matches(cls, specifier: str | SpecifierSet) -> bool:
 44        if isinstance(specifier, str):
 45            specifier = SpecifierSet(specifier)
 46        if (version := cls.get()) is None:
 47            return True
 48        return version in specifier
 49
 50
 51@dataclass(frozen=True)
 52class Versioned:
 53    """Base class for types which can behave differently based on a version source,
 54    which defaults to MinecraftVersion."""
 55
 56    version_spec: str
 57    version_source: VersionSource = field(default=MinecraftVersion, kw_only=True)
 58
 59    @property
 60    def is_current(self):
 61        return self.version_source.matches(self.version_spec)
 62
 63
 64@dataclass(frozen=True)
 65class IsVersion(Versioned):
 66    """Instances of this class are truthy if version_spec matches version_source, which
 67    defaults to MinecraftVersion.
 68
 69    Can be used as a Pydantic validator annotation, which raises ValueError if
 70    version_spec doesn't match the current version. Use it like this:
 71
 72    `Annotated[str, IsVersion(">=1.20")] | Annotated[None, IsVersion("<1.20")]`
 73
 74    Can also be used as a class decorator for Pydantic models, which raises ValueError
 75    when validating the model if version_spec doesn't match the current version.
 76    Decorated classes must subclass HexdocModel (or HexdocBaseModel).
 77    """
 78
 79    def __bool__(self):
 80        return self.is_current
 81
 82    def __call__(self, cls: _T_ModelType) -> _T_ModelType:
 83        cls.__hexdoc_before_validator__ = self._model_validator
 84        return cls
 85
 86    def __get_pydantic_core_schema__(
 87        self,
 88        source_type: type[Any],
 89        handler: GetCoreSchemaHandler,
 90    ) -> core_schema.CoreSchema:
 91        return core_schema.no_info_before_validator_function(
 92            self._schema_validator,
 93            schema=handler(source_type),
 94        )
 95
 96    def _schema_validator(self, value: Any):
 97        if self.is_current:
 98            return value
 99        raise ValueError(
100            f"Expected version {self.version_spec}, got {self.version_source.get()}"
101        )
102
103    def _model_validator(self, cls: Any, value: Any, info: ValidationInfo):
104        return self._schema_validator(value)
105
106
107Before_1_19 = Annotated[_T, IsVersion("<1.19")]
108"""Alias for `Annotated[_T, IsVersion("<1.19")]`."""
109AtLeast_1_19 = Annotated[_T, IsVersion(">=1.19")]
110"""Alias for `Annotated[_T, IsVersion(">=1.19")]`."""
111After_1_19 = Annotated[_T, IsVersion(">1.19")]
112"""Alias for `Annotated[_T, IsVersion("<1.19")]`."""
113
114Before_1_20 = Annotated[_T, IsVersion("<1.20")]
115"""Alias for `Annotated[_T, IsVersion("<1.20")]`."""
116AtLeast_1_20 = Annotated[_T, IsVersion(">=1.20")]
117"""Alias for `Annotated[_T, IsVersion(">=1.20")]`."""
118After_1_20 = Annotated[_T, IsVersion(">1.20")]
119"""Alias for `Annotated[_T, IsVersion("<1.20")]`."""
120
121
122@dataclass(frozen=True)
123class ValueIfVersion(Versioned, Generic[_If, _Else]):
124    value_if: _If
125    value_else: _Else
126
127    def __call__(self) -> _If | _Else:
128        if self.is_current:
129            return self.value_if
130        return self.value_else
class VersionSource(typing.Protocol):
22class VersionSource(Protocol):
23    @classmethod
24    def get(cls) -> str | None:
25        """Returns the current version."""
26        ...
27
28    @classmethod
29    def matches(cls, specifier: str | SpecifierSet) -> bool:
30        """Returns True if the current version matches the version_spec."""
31        ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...
VersionSource(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
@classmethod
def get(cls) -> str | None:
23    @classmethod
24    def get(cls) -> str | None:
25        """Returns the current version."""
26        ...

Returns the current version.

@classmethod
def matches(cls, specifier: str | packaging.specifiers.SpecifierSet) -> bool:
28    @classmethod
29    def matches(cls, specifier: str | SpecifierSet) -> bool:
30        """Returns True if the current version matches the version_spec."""
31        ...

Returns True if the current version matches the version_spec.

class MinecraftVersion(VersionSource):
34class MinecraftVersion(VersionSource):
35    MINECRAFT_VERSION: ClassVar[str | None] = None
36
37    @override
38    @classmethod
39    def get(cls) -> str | None:
40        return cls.MINECRAFT_VERSION
41
42    @override
43    @classmethod
44    def matches(cls, specifier: str | SpecifierSet) -> bool:
45        if isinstance(specifier, str):
46            specifier = SpecifierSet(specifier)
47        if (version := cls.get()) is None:
48            return True
49        return version in specifier

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...
MINECRAFT_VERSION: ClassVar[str | None] = None
@override
@classmethod
def get(cls) -> str | None:
37    @override
38    @classmethod
39    def get(cls) -> str | None:
40        return cls.MINECRAFT_VERSION

Returns the current version.

@override
@classmethod
def matches(cls, specifier: str | packaging.specifiers.SpecifierSet) -> bool:
42    @override
43    @classmethod
44    def matches(cls, specifier: str | SpecifierSet) -> bool:
45        if isinstance(specifier, str):
46            specifier = SpecifierSet(specifier)
47        if (version := cls.get()) is None:
48            return True
49        return version in specifier

Returns True if the current version matches the version_spec.

@dataclass(frozen=True)
class Versioned:
52@dataclass(frozen=True)
53class Versioned:
54    """Base class for types which can behave differently based on a version source,
55    which defaults to MinecraftVersion."""
56
57    version_spec: str
58    version_source: VersionSource = field(default=MinecraftVersion, kw_only=True)
59
60    @property
61    def is_current(self):
62        return self.version_source.matches(self.version_spec)

Base class for types which can behave differently based on a version source, which defaults to MinecraftVersion.

Versioned( version_spec: str, *, version_source: VersionSource = <class 'MinecraftVersion'>)
version_spec: str
version_source: VersionSource = <class 'MinecraftVersion'>
is_current
60    @property
61    def is_current(self):
62        return self.version_source.matches(self.version_spec)
@dataclass(frozen=True)
class IsVersion(Versioned):
 65@dataclass(frozen=True)
 66class IsVersion(Versioned):
 67    """Instances of this class are truthy if version_spec matches version_source, which
 68    defaults to MinecraftVersion.
 69
 70    Can be used as a Pydantic validator annotation, which raises ValueError if
 71    version_spec doesn't match the current version. Use it like this:
 72
 73    `Annotated[str, IsVersion(">=1.20")] | Annotated[None, IsVersion("<1.20")]`
 74
 75    Can also be used as a class decorator for Pydantic models, which raises ValueError
 76    when validating the model if version_spec doesn't match the current version.
 77    Decorated classes must subclass HexdocModel (or HexdocBaseModel).
 78    """
 79
 80    def __bool__(self):
 81        return self.is_current
 82
 83    def __call__(self, cls: _T_ModelType) -> _T_ModelType:
 84        cls.__hexdoc_before_validator__ = self._model_validator
 85        return cls
 86
 87    def __get_pydantic_core_schema__(
 88        self,
 89        source_type: type[Any],
 90        handler: GetCoreSchemaHandler,
 91    ) -> core_schema.CoreSchema:
 92        return core_schema.no_info_before_validator_function(
 93            self._schema_validator,
 94            schema=handler(source_type),
 95        )
 96
 97    def _schema_validator(self, value: Any):
 98        if self.is_current:
 99            return value
100        raise ValueError(
101            f"Expected version {self.version_spec}, got {self.version_source.get()}"
102        )
103
104    def _model_validator(self, cls: Any, value: Any, info: ValidationInfo):
105        return self._schema_validator(value)

Instances of this class are truthy if version_spec matches version_source, which defaults to MinecraftVersion.

Can be used as a Pydantic validator annotation, which raises ValueError if version_spec doesn't match the current version. Use it like this:

Annotated[str, IsVersion(">=1.20")] | Annotated[None, IsVersion("<1.20")]

Can also be used as a class decorator for Pydantic models, which raises ValueError when validating the model if version_spec doesn't match the current version. Decorated classes must subclass HexdocModel (or HexdocBaseModel).

IsVersion( version_spec: str, *, version_source: VersionSource = <class 'MinecraftVersion'>)
Before_1_19 = typing.Annotated[~_T, IsVersion(version_spec='<1.19', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion("<1.19")].

AtLeast_1_19 = typing.Annotated[~_T, IsVersion(version_spec='>=1.19', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion(">=1.19")].

After_1_19 = typing.Annotated[~_T, IsVersion(version_spec='>1.19', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion("<1.19")].

Before_1_20 = typing.Annotated[~_T, IsVersion(version_spec='<1.20', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion("<1.20")].

AtLeast_1_20 = typing.Annotated[~_T, IsVersion(version_spec='>=1.20', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion(">=1.20")].

After_1_20 = typing.Annotated[~_T, IsVersion(version_spec='>1.20', version_source=<class 'MinecraftVersion'>)]

Alias for Annotated[_T, IsVersion("<1.20")].

@dataclass(frozen=True)
class ValueIfVersion(Versioned, typing.Generic[~_If, ~_Else]):
123@dataclass(frozen=True)
124class ValueIfVersion(Versioned, Generic[_If, _Else]):
125    value_if: _If
126    value_else: _Else
127
128    def __call__(self) -> _If | _Else:
129        if self.is_current:
130            return self.value_if
131        return self.value_else
value_if: ~_If
value_else: ~_Else