Я создал класс Pydantic, предназначенный для анализа файлов JSON. Атрибут диапазон анализируется из строки вида "11-34"
(или, точнее, из показанного регулярного выражения):
RANGE_STRING_REGEX = r"^(?P<first>[1-6]+)(-(?P<last>[1-6]+))?$"
class RandomTableEvent(BaseModel):
name: str
range: Annotated[str, Field(regex=RANGE_STRING_REGEX)]
@validator("range", allow_reuse=True)
def convert_range_string_to_range(cls, r) -> "range":
match_groups = re.fullmatch(RANGE_STRING_REGEX, r).groupdict()
first = int(match_groups["first"])
last = int(match_groups["last"]) if match_groups["last"] else first
return range(first, last + 1)
Сгенерированная схема работает и проверка проходит.
Однако аннотация типа для атрибута диапазон в классе, строго говоря, неверна, так как атрибут диапазон преобразуется из строки (аннотации типа) в объект range
в функции валидатора.
Каким будет правильный способ аннотировать это и при этом поддерживать генерацию схемы? Есть ли другой способ справиться с этим неявным преобразованием типов (например, строки автоматически преобразуются в int в Pydantic - есть ли что-то подобное для пользовательских типов)?
range
не поддерживается pydantic
и использование его в качестве типа для поля вызовет ошибку при попытке создать схему JSON, но pydantic
поддерживает Пользовательские типы данных:
You can also define your own custom data types. There are several ways to achieve it.
Classes with get_validators
You use a custom class with a classmethod
__get_validators__
. It will be called to get validators to parse and validate the input data.
Но этот пользовательский тип данных не может наследоваться от range
, потому что он окончательный. Таким образом, вы можете создать собственный тип данных, который использует range
внутри и предоставляет методы диапазона: он будет работать как range
, но не будет range
(isinstance(..., range)
будет False
).
В той же pydantic
документации показано, как использовать __modify_schema__
метод для настройки схемы JSON пользовательского типа данных.
Полный пример:
import re
from typing import Any, Callable, Dict, Iterator, SupportsIndex, Union
from pydantic import BaseModel
class Range:
_RANGE_STRING_REGEX = r"^(?P<first>[1-6]+)(-(?P<last>[1-6]+))?$"
@classmethod
def __get_validators__(cls) -> Iterator[Callable[[Any], Any]]:
yield cls.validate
@classmethod
def validate(cls, v: Any) -> "Range":
if not isinstance(v, str):
raise ValueError("expected string")
match = re.fullmatch(cls._RANGE_STRING_REGEX, v)
if not match:
raise ValueError("invalid string")
match_groups = match.groupdict()
first = int(match_groups["first"])
last = int(match_groups["last"]) if match_groups["last"] else first
return cls(range(first, last + 1))
def __init__(self, r: range) -> None:
self._range = r
@classmethod
def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
# Customize the JSON schema as you want
field_schema["pattern"] = cls._RANGE_STRING_REGEX
field_schema["type"] = "string"
# Implement the range methods and use self._range
@property
def start(self) -> int:
return self._range.start
@property
def stop(self) -> int:
return self._range.stop
@property
def step(self) -> int:
return self._range.step
def count(self, value: int) -> int:
return self._range.count(value)
def index(self, value: int) -> int:
return self._range.index(value)
def __len__(self) -> int:
return self._range.__len__()
def __contains__(self, o: object) -> bool:
return self._range.__contains__(o)
def __iter__(self) -> Iterator[int]:
return self._range.__iter__()
def __getitem__(self, key: Union[SupportsIndex, slice]) -> int:
return self._range.__getitem__(key)
def __reversed__(self) -> Iterator[int]:
return self._range.__reversed__()
def __repr__(self) -> str:
return self._range.__repr__()
class RandomTableEvent(BaseModel):
name: str
range: Range
event = RandomTableEvent(name = "foo", range = "11-34")
print("event:", event)
print("event.range:", event.range)
print("schema:", event.schema_json(indent=2))
print("is instance of range:", isinstance(event.range, range))
print("event.range.start:", event.range.start)
print("event.range.stop:", event.range.stop)
print("event.range[0:5]", event.range[0:5])
print("last 3 elements:", list(event.range[-3:]))
Выход:
event: name='foo' range=range(11, 35)
event.range: range(11, 35)
schema: {
"title": "RandomTableEvent",
"type": "object",
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"range": {
"title": "Range",
"pattern": "^(?P<first>[1-6]+)(-(?P<last>[1-6]+))?$",
"type": "string"
}
},
"required": [
"name",
"range"
]
}
is instance of range: False
event.range.start: 11
event.range.stop: 35
event.range[0:5] range(11, 16)
last 3 elements: [32, 33, 34]
Большое спасибо за это подробное решение. Обтекание диапазона проприетарным классом кажется немного неправильным, но я вижу, что это решает проблему очень элегантно.