From 4942a4a5c3dd50a7246d816a28b9feab631b2070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 08:33:52 +0100 Subject: [PATCH 1/7] Epoch - unix timestamp using type float, format: date-time to serialize datetime.datetime --- pydantic_extra_types/epoch.py | 35 +++++++++++++++++++++++++++++++++ tests/test_epoch.py | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 pydantic_extra_types/epoch.py create mode 100644 tests/test_epoch.py diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py new file mode 100644 index 0000000..f2b1264 --- /dev/null +++ b/pydantic_extra_types/epoch.py @@ -0,0 +1,35 @@ +import datetime +from typing import Any, Callable + +from pydantic import GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, core_schema + +EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + + +class Epoch(datetime.datetime): + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + field_schema: dict[str, Any] = {} + field_schema.update(type='number', format='date-time') + return field_schema + + @classmethod + def __get_pydantic_core_schema__( + cls, source: type[Any], handler: Callable[[Any], CoreSchema] + ) -> core_schema.CoreSchema: + def f(value, serializer): + return serializer(value.timestamp()) + return core_schema.with_info_after_validator_function( + cls._validate, core_schema.float_schema(), + serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=core_schema.float_schema()) + ) + + @classmethod + def _validate(cls, __input_value: Any, _: Any) -> "Epoch": + return EPOCH + datetime.timedelta(seconds=__input_value) + + diff --git a/tests/test_epoch.py b/tests/test_epoch.py new file mode 100644 index 0000000..d38f821 --- /dev/null +++ b/tests/test_epoch.py @@ -0,0 +1,37 @@ +import datetime + +import pytest + +from pydantic_extra_types.epoch import Epoch + +@pytest.mark.parametrize('type_',[(int,),(float,)], ids=["integer", "float"]) +def test_type(type_): + from pydantic import BaseModel + + class A(BaseModel): + epoch: Epoch + + now = datetime.datetime.now(tz=datetime.timezone.utc) + ts = type_(now.timestamp()) + a = A.model_validate({"epoch":ts}) + v = a.model_dump() + assert v["epoch"] == ts + + b = A.model_construct(epoch=now) + + v = b.model_dump() + assert v["epoch"] == ts + + c = A.model_validate(dict(epoch=ts)) + v = c.model_dump() + assert v["epoch"] == ts + + +def test_schema(): + from pydantic import BaseModel + class A(BaseModel): + dt: Epoch + + v = A.model_json_schema() + assert (dt:=v["properties"]["dt"])["type"] == "number" and dt["format"] == "date-time" + From 6cd0de02af22eace3559bb5c0f5d64a9a9868447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 08:44:07 +0100 Subject: [PATCH 2/7] liniting --- pydantic_extra_types/epoch.py | 12 ++++++------ tests/test_epoch.py | 15 ++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index f2b1264..40dc331 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -21,15 +21,15 @@ def __get_pydantic_json_schema__( def __get_pydantic_core_schema__( cls, source: type[Any], handler: Callable[[Any], CoreSchema] ) -> core_schema.CoreSchema: - def f(value, serializer): + def f(value: Any, serializer: Callable[[datetime.datetime], float]) -> float: return serializer(value.timestamp()) + return core_schema.with_info_after_validator_function( - cls._validate, core_schema.float_schema(), - serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=core_schema.float_schema()) + cls._validate, + core_schema.float_schema(), + serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=core_schema.float_schema()), ) @classmethod - def _validate(cls, __input_value: Any, _: Any) -> "Epoch": + def _validate(cls, __input_value: Any, _: Any) -> datetime.datetime: return EPOCH + datetime.timedelta(seconds=__input_value) - - diff --git a/tests/test_epoch.py b/tests/test_epoch.py index d38f821..5f90077 100644 --- a/tests/test_epoch.py +++ b/tests/test_epoch.py @@ -4,7 +4,8 @@ from pydantic_extra_types.epoch import Epoch -@pytest.mark.parametrize('type_',[(int,),(float,)], ids=["integer", "float"]) + +@pytest.mark.parametrize('type_', [(int,), (float,)], ids=['integer', 'float']) def test_type(type_): from pydantic import BaseModel @@ -13,25 +14,25 @@ class A(BaseModel): now = datetime.datetime.now(tz=datetime.timezone.utc) ts = type_(now.timestamp()) - a = A.model_validate({"epoch":ts}) + a = A.model_validate({'epoch': ts}) v = a.model_dump() - assert v["epoch"] == ts + assert v['epoch'] == ts b = A.model_construct(epoch=now) v = b.model_dump() - assert v["epoch"] == ts + assert v['epoch'] == ts c = A.model_validate(dict(epoch=ts)) v = c.model_dump() - assert v["epoch"] == ts + assert v['epoch'] == ts def test_schema(): from pydantic import BaseModel + class A(BaseModel): dt: Epoch v = A.model_json_schema() - assert (dt:=v["properties"]["dt"])["type"] == "number" and dt["format"] == "date-time" - + assert (dt := v['properties']['dt'])['type'] == 'number' and dt['format'] == 'date-time' From aa280c6868567666b5b14213f50099c0907a5e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 09:08:05 +0100 Subject: [PATCH 3/7] epoch - split into number & integer --- pydantic_extra_types/epoch.py | 30 ++++++++++++++++++++++++------ tests/test_epoch.py | 15 ++++++++------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index 40dc331..1492bac 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -1,6 +1,7 @@ import datetime -from typing import Any, Callable +from typing import Any, Callable, Optional +import pydantic_core.core_schema from pydantic import GetJsonSchemaHandler from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema, core_schema @@ -8,13 +9,17 @@ EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) -class Epoch(datetime.datetime): +class _Base(datetime.datetime): + TYPE: str = '' + CLS: Optional[Callable[[Any], Any]] + SCHEMA: pydantic_core.core_schema.CoreSchema + @classmethod def __get_pydantic_json_schema__( cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: field_schema: dict[str, Any] = {} - field_schema.update(type='number', format='date-time') + field_schema.update(type=cls.TYPE, format='date-time') return field_schema @classmethod @@ -22,14 +27,27 @@ def __get_pydantic_core_schema__( cls, source: type[Any], handler: Callable[[Any], CoreSchema] ) -> core_schema.CoreSchema: def f(value: Any, serializer: Callable[[datetime.datetime], float]) -> float: - return serializer(value.timestamp()) + ts = value.timestamp() + return serializer(cls.CLS(ts) if cls.CLS is not None else ts) return core_schema.with_info_after_validator_function( cls._validate, - core_schema.float_schema(), - serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=core_schema.float_schema()), + cls.SCHEMA, + serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=cls.SCHEMA), ) @classmethod def _validate(cls, __input_value: Any, _: Any) -> datetime.datetime: return EPOCH + datetime.timedelta(seconds=__input_value) + + +class Number(_Base): + TYPE = 'number' + CLS = None + SCHEMA = core_schema.float_schema() + + +class Integer(_Base): + TYPE = 'integer' + CLS = int + SCHEMA = core_schema.int_schema() diff --git a/tests/test_epoch.py b/tests/test_epoch.py index 5f90077..5cbfb62 100644 --- a/tests/test_epoch.py +++ b/tests/test_epoch.py @@ -2,15 +2,15 @@ import pytest -from pydantic_extra_types.epoch import Epoch +from pydantic_extra_types import epoch -@pytest.mark.parametrize('type_', [(int,), (float,)], ids=['integer', 'float']) -def test_type(type_): +@pytest.mark.parametrize('type_,cls_', [(int, epoch.Integer), (float, epoch.Number)], ids=['integer', 'number']) +def test_type(type_, cls_): from pydantic import BaseModel class A(BaseModel): - epoch: Epoch + epoch: cls_ now = datetime.datetime.now(tz=datetime.timezone.utc) ts = type_(now.timestamp()) @@ -28,11 +28,12 @@ class A(BaseModel): assert v['epoch'] == ts -def test_schema(): +@pytest.mark.parametrize('cls_', [(epoch.Integer), (epoch.Number)], ids=['integer', 'number']) +def test_schema(cls_): from pydantic import BaseModel class A(BaseModel): - dt: Epoch + dt: cls_ v = A.model_json_schema() - assert (dt := v['properties']['dt'])['type'] == 'number' and dt['format'] == 'date-time' + assert (dt := v['properties']['dt'])['type'] == cls_.TYPE and dt['format'] == 'date-time' From f9c93d16438b85e134dec144241f7df591695375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 09:10:51 +0100 Subject: [PATCH 4/7] compat - 3.8 typing tests/test_epoch.py:5: in from pydantic_extra_types import epoch pydantic_extra_types/epoch.py:12: in class _Base(datetime.datetime): pydantic_extra_types/epoch.py:27: in _Base cls, source: type[Any], handler: Callable[[Any], CoreSchema] E TypeError: 'type' object is not subscriptable --- pydantic_extra_types/epoch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index 1492bac..93ca05e 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime from typing import Any, Callable, Optional From efe14110b02fb3e9128f20d27701657098184db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 09:46:27 +0100 Subject: [PATCH 5/7] =?UTF-8?q?typing=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pydantic_extra_types/epoch.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index 93ca05e..b4e4dba 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime -from typing import Any, Callable, Optional +from typing import Any, Callable import pydantic_core.core_schema from pydantic import GetJsonSchemaHandler @@ -13,7 +13,6 @@ class _Base(datetime.datetime): TYPE: str = '' - CLS: Optional[Callable[[Any], Any]] SCHEMA: pydantic_core.core_schema.CoreSchema @classmethod @@ -28,28 +27,36 @@ def __get_pydantic_json_schema__( def __get_pydantic_core_schema__( cls, source: type[Any], handler: Callable[[Any], CoreSchema] ) -> core_schema.CoreSchema: - def f(value: Any, serializer: Callable[[datetime.datetime], float]) -> float: - ts = value.timestamp() - return serializer(cls.CLS(ts) if cls.CLS is not None else ts) - return core_schema.with_info_after_validator_function( cls._validate, cls.SCHEMA, - serialization=core_schema.wrap_serializer_function_ser_schema(f, return_schema=cls.SCHEMA), + serialization=core_schema.wrap_serializer_function_ser_schema(cls._f, return_schema=cls.SCHEMA), ) @classmethod def _validate(cls, __input_value: Any, _: Any) -> datetime.datetime: return EPOCH + datetime.timedelta(seconds=__input_value) + @classmethod + def _f(cls, value: Any, serializer: Callable[[Any], Any]) -> Any: + pass + class Number(_Base): TYPE = 'number' - CLS = None SCHEMA = core_schema.float_schema() + @classmethod + def _f(cls, value: Any, serializer: Callable[[float], float]) -> float: + ts = value.timestamp() + return serializer(ts) + class Integer(_Base): TYPE = 'integer' - CLS = int SCHEMA = core_schema.int_schema() + + @classmethod + def _f(cls, value: Any, serializer: Callable[[int], int]) -> int: + ts = value.timestamp() + return serializer(int(ts)) From 6f0b7652dca1568ed554e1cbc43054e347d9acdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 16 Nov 2024 10:10:22 +0100 Subject: [PATCH 6/7] coverage --- pydantic_extra_types/epoch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index b4e4dba..2092cb2 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -38,8 +38,8 @@ def _validate(cls, __input_value: Any, _: Any) -> datetime.datetime: return EPOCH + datetime.timedelta(seconds=__input_value) @classmethod - def _f(cls, value: Any, serializer: Callable[[Any], Any]) -> Any: - pass + def _f(cls, value: Any, serializer: Callable[[Any], Any]) -> Any: # pragma: no cover + raise NotImplementedError(cls) class Number(_Base): From e4becc902e3c3eeb59497ceb859177116e2b8b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 20 Nov 2024 19:52:55 +0100 Subject: [PATCH 7/7] epoch - docs & tests --- pydantic_extra_types/epoch.py | 33 +++++++++++++++++++++++++++++++++ tests/test_json_schema.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/pydantic_extra_types/epoch.py b/pydantic_extra_types/epoch.py index 2092cb2..c9dda44 100644 --- a/pydantic_extra_types/epoch.py +++ b/pydantic_extra_types/epoch.py @@ -43,6 +43,22 @@ def _f(cls, value: Any, serializer: Callable[[Any], Any]) -> Any: # pragma: no class Number(_Base): + """epoch.Number parses unix timestamp as float and converts it to datetime. + + ```py + from pydantic import BaseModel + + from pydantic_extra_types import epoch + + class LogEntry(BaseModel): + timestamp: epoch.Number + + logentry = LogEntry(timestamp=1.1) + print(logentry) + #> timestamp=datetime.datetime(1970, 1, 1, 0, 0, 1, 100000, tzinfo=datetime.timezone.utc) + ``` + """ + TYPE = 'number' SCHEMA = core_schema.float_schema() @@ -53,6 +69,23 @@ def _f(cls, value: Any, serializer: Callable[[float], float]) -> float: class Integer(_Base): + """epoch.Integer parses unix timestamp as integer and converts it to datetime. + + ``` + ```py + from pydantic import BaseModel + + from pydantic_extra_types import epoch + + class LogEntry(BaseModel): + timestamp: epoch.Integer + + logentry = LogEntry(timestamp=1) + print(logentry) + #> timestamp=datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc) + ``` + """ + TYPE = 'integer' SCHEMA = core_schema.int_schema() diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 0580279..18d87ea 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -11,6 +11,7 @@ from typing_extensions import Annotated import pydantic_extra_types +from pydantic_extra_types import epoch from pydantic_extra_types.color import Color from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude from pydantic_extra_types.country import CountryAlpha2, CountryAlpha3, CountryNumericCode, CountryShortName @@ -464,6 +465,40 @@ ], }, ), + ( + epoch.Integer, + { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'x': { + 'title': 'X', + 'type': 'integer', + 'format': 'date-time', + }, + }, + 'required': [ + 'x', + ], + }, + ), + ( + epoch.Number, + { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'x': { + 'title': 'X', + 'type': 'number', + 'format': 'date-time', + }, + }, + 'required': [ + 'x', + ], + }, + ), ], ) def test_json_schema(cls, expected):