Skip to content

Commit 314ec3d

Browse files
authored
Epoch - data type for unix timestamps using pydantic-extra-types (#300)
* v20/v3x - epoch datetime values have a datatype for epoch datetimes type:integer/number with format:date-time is datetime c.f. pydantic/pydantic-extra-types#240
1 parent adc5765 commit 314ec3d

File tree

11 files changed

+159
-12
lines changed

11 files changed

+159
-12
lines changed

aiopenapi3/model.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .base import DiscriminatorBase
2525
from ._types import SchemaType, ReferenceType, PrimitiveTypes, DiscriminatorType
2626

27-
type_format_to_class: dict[str, dict[str, type]] = collections.defaultdict(dict)
27+
type_format_to_class: dict[str, dict[Optional[str], type]] = collections.defaultdict(dict)
2828

2929
log = logging.getLogger("aiopenapi3.model")
3030

@@ -58,11 +58,20 @@ def generate_type_format_to_class():
5858

5959
type_format_to_class["string"]["byte"] = Base64Str
6060

61+
type_format_to_class["integer"][None] = int
62+
63+
try:
64+
from pydantic_extra_types import epoch
65+
66+
type_format_to_class["number"]["date-time"] = epoch.Number
67+
type_format_to_class["integer"]["date-time"] = epoch.Integer
68+
69+
except ImportError:
70+
pass
71+
6172

6273
def class_from_schema(s, _type):
63-
if _type == "integer":
64-
return int
65-
elif _type == "boolean":
74+
if _type == "boolean":
6675
return bool
6776
a = type_format_to_class[_type]
6877
b = a.get(s.format, a[None])

aiopenapi3/models/epoch.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
from typing import Any, Callable
5+
6+
import pydantic_core.core_schema
7+
from pydantic import GetJsonSchemaHandler
8+
from pydantic.json_schema import JsonSchemaValue
9+
from pydantic_core import CoreSchema, core_schema
10+
11+
EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
12+
13+
14+
class _Base(datetime.datetime):
15+
TYPE: str = ""
16+
SCHEMA: pydantic_core.core_schema.CoreSchema
17+
18+
@classmethod
19+
def __get_pydantic_json_schema__(
20+
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
21+
) -> JsonSchemaValue:
22+
field_schema: dict[str, Any] = {}
23+
field_schema.update(type=cls.TYPE, format="date-time")
24+
return field_schema
25+
26+
@classmethod
27+
def __get_pydantic_core_schema__(
28+
cls, source: type[Any], handler: Callable[[Any], CoreSchema]
29+
) -> core_schema.CoreSchema:
30+
return core_schema.with_info_after_validator_function(
31+
cls._validate,
32+
cls.SCHEMA,
33+
serialization=core_schema.wrap_serializer_function_ser_schema(cls._f, return_schema=cls.SCHEMA),
34+
)
35+
36+
@classmethod
37+
def _validate(cls, __input_value: Any, _: Any) -> datetime.datetime:
38+
return EPOCH + datetime.timedelta(seconds=__input_value)
39+
40+
@classmethod
41+
def _f(cls, value: Any, serializer: Callable[[Any], Any]) -> Any: # pragma: no cover
42+
raise NotImplementedError(cls)
43+
44+
45+
class Number(_Base):
46+
TYPE = "number"
47+
SCHEMA = core_schema.float_schema()
48+
49+
@classmethod
50+
def _f(cls, value: Any, serializer: Callable[[float], float]) -> float:
51+
ts = value.timestamp()
52+
return serializer(ts)
53+
54+
55+
class Integer(_Base):
56+
TYPE = "integer"
57+
SCHEMA = core_schema.int_schema()
58+
59+
@classmethod
60+
def _f(cls, value: Any, serializer: Callable[[int], int]) -> int:
61+
ts = value.timestamp()
62+
return serializer(int(ts))

docs/source/advanced.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,14 @@ Limiting the concurrency to a certain number of clients:
484484
break
485485
else:
486486
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
487+
488+
Epoch Types
489+
===========
490+
491+
If installed, pydantic-extra-types is used to provide an epoch data type for integers and float values mapping to datetime.datetime.
492+
493+
A :ref:`Document Plugin <plugin:Document>` can be used to modify a description document to add a format: date-time to the numeric type definition for a posix timestamp.
494+
495+
.. code:: yaml
496+
type: integer
497+
format: date-time

docs/source/plugin.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ Using a Document plugin to modify the parsed description document to state the c
128128
api = OpenAPI.load_sync("https://try.gitea.io/swagger.v1.json", plugins=[ContentType()])
129129
130130
131+
Another example is adding the "format" specifier to an epoch timestamp to have it de-serialized as datetime instead of number/integer.
132+
133+
.. code:: python
134+
135+
class EpochTimestamp(aiopenapi3.plugin.Document):
136+
def parsed(self, ctx):
137+
ctx.document["components"]["schemas"]["LogEvent"]["properties"]["timestamp"]["format"] = "date-time"
138+
return ctx
139+
140+
131141
Message
132142
=======
133143

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ auth = [
5454
socks = [
5555
"httpx-socks",
5656
]
57-
57+
types =[
58+
"pydantic-extra-types @ git+https://github.com/pydantic/pydantic-extra-types.git@3668b3af8ab378c56342c613672aa9415dab865b",
59+
]
5860
[project.scripts]
5961
aiopenapi3 = "aiopenapi3.cli:main"
6062

@@ -118,6 +120,7 @@ tests = [
118120
"bootstrap-flask",
119121
"ijson",
120122
"python-multipart>=0.0.6",
123+
"pydantic-extra-types @ git+https://github.com/pydantic/pydantic-extra-types.git@3668b3af8ab378c56342c613672aa9415dab865b"
121124
]
122125

123126
[tool.pdm]

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,11 @@ def with_schema_additionalProperties_and_named_properties():
440440
yield _get_parsed_yaml("schema-additionalProperties-and-named-properties" ".yaml")
441441

442442

443+
@pytest.fixture
444+
def with_schema_date_types():
445+
yield _get_parsed_yaml("schema-date-types.yaml")
446+
447+
443448
@pytest.fixture
444449
def with_schema_boolean_v20():
445450
yield _get_parsed_yaml("schema-boolean-v20.yaml")

tests/fixtures/petstore-expanded.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,13 @@ components:
131131
- type: object
132132
required:
133133
- id
134+
- properties
134135
properties:
135136
id:
136137
type: integer
137138
format: int64
139+
created:
140+
type: integer
138141

139142
NewPet:
140143
type: object

tests/fixtures/schema-date-types.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
openapi: "3.1.0"
2+
info:
3+
version: 1.0.0
4+
title: date-time
5+
6+
components:
7+
schemas:
8+
Integer:
9+
type: integer
10+
format: date-time
11+
12+
Number:
13+
type: number
14+
format: date-time
15+
16+
String:
17+
type: string
18+
format: date-time

tests/plugin_test.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import httpx
2-
1+
import datetime
32
from pathlib import Path
43

4+
import httpx
55
import yarl
66

77
from aiopenapi3 import FileSystemLoader, OpenAPI
@@ -43,6 +43,7 @@ def parsed(self, ctx):
4343
},
4444
}
4545
)
46+
ctx.document["components"]["schemas"]["Pet"]["allOf"][1]["properties"]["created"]["format"] = "date-time"
4647
else:
4748
raise ValueError(f"unexpected url {ctx.url.path} expecting {self.url}")
4849

@@ -57,7 +58,7 @@ def sending(self, ctx):
5758
return ctx
5859

5960
def received(self, ctx):
60-
ctx.received = """[{"id":1,"name":"theanimal", "weight": null}]"""
61+
ctx.received = """[{"id":1,"name":"theanimal", "created":4711,"weight": null}]"""
6162
return ctx
6263

6364
def parsed(self, ctx):
@@ -94,3 +95,4 @@ def test_Plugins(httpx_mock, with_plugin_base):
9495
assert item.id == 3
9596
assert item.weight == None # default does not apply as it it unsed
9697
assert item.color == "red" # default does not apply
98+
assert item.created == datetime.datetime.fromtimestamp(4711, tz=datetime.timezone.utc)

tests/ref_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,23 @@ def test_allOf_resolution(petstore_expanded):
6565
pass
6666
items = pet.model_fields
6767

68-
assert sorted(items.keys()) == ["id", "name", "tag"]
68+
assert sorted(items.keys()) == ["created", "id", "name", "tag"]
6969

7070
def is_nullable(x):
7171
# Optional[…] or | None
7272
return typing.get_origin(x.annotation) == typing.Union and type(None) in typing.get_args(x.annotation)
7373

7474
assert sorted(map(lambda x: x[0], filter(lambda y: is_nullable(y[1]), items.items()))) == sorted(
75-
["tag"]
76-
), ref.schema()
75+
["created", "tag"]
76+
), ref.model_json_schema()
7777

7878
def is_required(x):
7979
# not assign a default '= Field(default=…)' or '= …'
8080
return x.default == pydantic_core.PydanticUndefined
8181

8282
assert sorted(map(lambda x: x[0], filter(lambda y: is_required(y[1]), items.items()))) == sorted(
8383
["id", "name"]
84-
), ref.schema()
84+
), ref.model_json_schema()
8585

8686
assert items["id"].annotation == int
8787
assert items["name"].annotation == str

tests/schema_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
import typing
33
import uuid
4+
from datetime import datetime
45
from unittest.mock import MagicMock, patch
56

67
from pydantic.fields import FieldInfo
@@ -766,3 +767,26 @@ def test_schema_boolean_v20(with_schema_boolean_v20):
766767

767768
with pytest.raises(ValidationError):
768769
B.model_validate({"b": 1})
770+
771+
772+
def test_schema_date_types(with_schema_date_types):
773+
api = OpenAPI("/", with_schema_date_types)
774+
Integer = api.components.schemas["Integer"].get_type()
775+
Number = api.components.schemas["Number"].get_type()
776+
String = api.components.schemas["String"].get_type()
777+
778+
from datetime import timezone
779+
780+
now = datetime.now(tz=timezone.utc)
781+
ts = now.timestamp()
782+
v = Integer.model_validate(c := int(ts))
783+
assert isinstance(v.root, datetime)
784+
assert v.model_dump() == c
785+
786+
v = Number.model_validate(ts)
787+
assert isinstance(v.root, datetime)
788+
assert v.model_dump() == ts
789+
790+
v = String.model_validate(str(ts))
791+
assert isinstance(v.root, datetime)
792+
assert v.model_dump_json()[1:-1] == now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

0 commit comments

Comments
 (0)