comicbox.fields.number_fields

[docs] module comicbox.fields.number_fields

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""Marshmallow number fields."""

import re
from decimal import Decimal
from typing import Any

from loguru import logger
from marshmallow import fields
from typing_extensions import override

from comicbox.empty import is_empty
from comicbox.fields.fields import (
    StringField,
    TrapExceptionsMeta,
    half_replace,
)

NumberType = int | float | Decimal
PAGE_COUNT_KEY = "page_count"


class RangedNumberMixin(metaclass=TrapExceptionsMeta):
    """Number range methods."""

    ZERO_FILL: int = 0

    def _set_range(
        self, minimum: NumberType | None, maximum: NumberType | None
    ) -> None:
        self._min = minimum
        self._max = maximum

    @classmethod
    def parse_str(cls, num_obj) -> NumberType | None:
        """Parse numerical string method."""
        raise NotImplementedError

    def _deserialize_pre(self, value: Any) -> NumberType | None:
        result = self.parse_str(value) if isinstance(value, str) else value
        if is_empty(result):
            return None
        return result

    def _deserialize_post(self, value: NumberType | None) -> NumberType | None:
        result: NumberType | None = value
        if result is not None:
            old_result = result
            if self._min is not None:
                result = max(result, self._min)
            if self._max is not None:
                result = min(result, self._max)
            if old_result != result:
                logger.warning(f"Coerced {old_result} to {result}")
        return result

    def _serialize_post(self, result: Any) -> Any:
        """Zero pad as_string numbers for sorting."""
        if self.as_string and self.ZERO_FILL and result is not None:  # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute]
            result = str(result).zfill(self.ZERO_FILL)
        return result


class IntegerField(fields.Integer, RangedNumberMixin):
    """Durable integer field."""

    _FIRST_NUMBER_MATCHER = re.compile(r"\d+")

    @override
    @classmethod
    def parse_str(cls, num_obj: str) -> int | None:
        """Parse the first number out of volume."""
        num_str: str | None = StringField().deserialize(num_obj)
        if not num_str:
            return None
        match: re.Match | None = cls._FIRST_NUMBER_MATCHER.search(num_str)
        if match:
            return int(match.group())
        return None

    def __init__(
        self,
        *args: Any,
        minimum: int | None = None,
        maximum: int | None = None,
        **kwargs: Any,
    ) -> None:
        """Set the min and max value."""
        super().__init__(*args, **kwargs)
        self._set_range(minimum, maximum)

    @override
    def _deserialize(self, value: int | str, *args: Any, **kwargs: Any) -> int | None:  #  pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override]
        pre_value = self._deserialize_pre(value)
        result = super()._deserialize(pre_value, *args, **kwargs)
        return self._deserialize_post(result)  #  pyright: ignore[reportReturnType], # ty: ignore[invalid-return-type]

    @override
    def _serialize(self, *args: Any, **kwargs: Any) -> int:
        result = super()._serialize(*args, **kwargs)
        return self._serialize_post(result)


class DecimalField(fields.Decimal, RangedNumberMixin):
    """Durable Decimal field that parses some fractions."""

    DECIMAL_MATCHER = re.compile(r"\d*\.?\d+")

    @override
    @classmethod
    def parse_str(cls, num_obj: str) -> Decimal | None:
        """Fix half glyphs."""
        num_str: str | None = StringField().deserialize(num_obj)
        if not num_str:
            return None
        num_str = num_str.replace(" ", "")
        num_str = half_replace(num_str)
        match = cls.DECIMAL_MATCHER.search(num_str)
        if match:
            return Decimal(match.group())
        return None

    def __init__(
        self,
        *args: Any,
        minimum: Decimal | None = None,
        maximum: Decimal | None = None,
        **kwargs: Any,
    ) -> None:
        """Set the min and max value."""
        super().__init__(*args, **kwargs)
        self._set_range(minimum, maximum)

    @override
    def _deserialize(  # pyright: ignore[reportIncompatibleMethodOverride]  # ty: ignore[invalid-method-override]
        self, value: Any, *args: Any, **kwargs: Any
    ) -> Decimal | None:
        pre_value = self._deserialize_pre(value)
        result = super()._deserialize(pre_value, *args, **kwargs)
        return self._deserialize_post(result)  #  pyright: ignore[reportReturnType], # ty: ignore[invalid-return-type]

    @override
    def _serialize(self, *args: Any, **kwargs: Any) -> Any:
        result = super()._serialize(*args, **kwargs)
        return self._serialize_post(result)


class BooleanField(fields.Boolean, metaclass=TrapExceptionsMeta):
    """A liberally parsed boolean field."""