From fa0ec99bf2ba891e3cb9e5e06798530424f6cabc Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 18 Nov 2024 11:47:40 +0200 Subject: [PATCH 1/2] feat: draft implementation for JSON format. Only CloudEvent serialization included. Signed-off-by: Tudor Plugaru --- src/cloudevents/core/base.py | 41 ++++++ src/cloudevents/core/formats/__init__.py | 13 ++ src/cloudevents/core/formats/base.py | 24 ++++ src/cloudevents/core/formats/json.py | 74 +++++++++++ src/cloudevents/core/v1/event.py | 11 +- tests/test_core/test_format/__init__.py | 13 ++ tests/test_core/test_format/test_json.py | 157 +++++++++++++++++++++++ 7 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/cloudevents/core/base.py create mode 100644 src/cloudevents/core/formats/__init__.py create mode 100644 src/cloudevents/core/formats/base.py create mode 100644 src/cloudevents/core/formats/json.py create mode 100644 tests/test_core/test_format/__init__.py create mode 100644 tests/test_core/test_format/test_json.py diff --git a/src/cloudevents/core/base.py b/src/cloudevents/core/base.py new file mode 100644 index 00000000..6371d6f1 --- /dev/null +++ b/src/cloudevents/core/base.py @@ -0,0 +1,41 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from datetime import datetime +from typing import Any, Optional, Protocol, Union + + +class BaseCloudEvent(Protocol): + def get_id(self) -> str: ... + + def get_source(self) -> str: ... + + def get_type(self) -> str: ... + + def get_specversion(self) -> str: ... + + def get_datacontenttype(self) -> Optional[str]: ... + + def get_dataschema(self) -> Optional[str]: ... + + def get_subject(self) -> Optional[str]: ... + + def get_time(self) -> Optional[datetime]: ... + + def get_extension(self, extension_name: str) -> Any: ... + + def get_data(self) -> Optional[Union[dict, str, bytes]]: ... + + def get_attributes(self) -> dict[str, Any]: ... diff --git a/src/cloudevents/core/formats/__init__.py b/src/cloudevents/core/formats/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/src/cloudevents/core/formats/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/src/cloudevents/core/formats/base.py b/src/cloudevents/core/formats/base.py new file mode 100644 index 00000000..859a684b --- /dev/null +++ b/src/cloudevents/core/formats/base.py @@ -0,0 +1,24 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from typing import Protocol, Union + +from cloudevents.core.base import BaseCloudEvent + + +class Format(Protocol): + def read(self, data: Union[str, bytes]) -> BaseCloudEvent: ... + + def write(self, event: BaseCloudEvent) -> str: ... diff --git a/src/cloudevents/core/formats/json.py b/src/cloudevents/core/formats/json.py new file mode 100644 index 00000000..930b0da8 --- /dev/null +++ b/src/cloudevents/core/formats/json.py @@ -0,0 +1,74 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import base64 +import re +from datetime import datetime +from json import JSONEncoder, dumps +from typing import Any, Final, Pattern, Union + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.formats.base import Format + + +class _JSONEncoderWithDatetime(JSONEncoder): + """ + Custom JSON encoder to handle datetime objects in the format required by the CloudEvents spec. + """ + + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + dt = obj.isoformat() + # 'Z' denotes a UTC offset of 00:00 see + # https://www.rfc-editor.org/rfc/rfc3339#section-2 + if dt.endswith("+00:00"): + dt = dt.removesuffix("+00:00") + "Z" + return dt + + return super().default(obj) + + +class JSONFormat(Format): + CONTENT_TYPE: Final[str] = "application/cloudevents+json" + JSON_CONTENT_TYPE_PATTERN: Pattern[str] = re.compile( + r"^(application|text)\\/([a-zA-Z]+\\+)?json(;.*)*$" + ) + + def read(self, data: Union[str, bytes]) -> BaseCloudEvent: + pass + + def write(self, event: BaseCloudEvent) -> bytes: + """ + Write a CloudEvent to a JSON formatted byte string. + + :param event: The CloudEvent to write. + :return: The CloudEvent as a JSON formatted byte array. + """ + event_data = event.get_data() + event_dict: dict[str, Any] = {**event.get_attributes()} + + if event_data is not None: + if isinstance(event_data, (bytes, bytearray)): + event_dict["data_base64"] = base64.b64encode(event_data).decode("utf-8") + else: + datacontenttype = event_dict.get( + "datacontenttype", JSONFormat.CONTENT_TYPE + ) + if re.match(JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype): + event_dict["data"] = dumps(event_data) + else: + event_dict["data"] = str(event_data) + + return dumps(event_dict, cls=_JSONEncoderWithDatetime).encode("utf-8") diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 043670b5..e9d9f874 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -17,6 +17,7 @@ from datetime import datetime from typing import Any, Final, Optional +from cloudevents.core.base import BaseCloudEvent from cloudevents.core.v1.exceptions import ( BaseCloudEventException, CloudEventValidationError, @@ -35,7 +36,7 @@ ] -class CloudEvent: +class CloudEvent(BaseCloudEvent): """ The CloudEvent Python wrapper contract exposing generically-available properties and APIs. @@ -322,3 +323,11 @@ def get_data(self) -> Optional[dict]: :return: The data of the event. """ return self._data + + def get_attributes(self) -> dict[str, Any]: + """ + Retrieve all attributes of the event. + + :return: The attributes of the event. + """ + return self._attributes diff --git a/tests/test_core/test_format/__init__.py b/tests/test_core/test_format/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/test_format/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_core/test_format/test_json.py b/tests/test_core/test_format/test_json.py new file mode 100644 index 00000000..f8ad31f6 --- /dev/null +++ b/tests/test_core/test_format/test_json.py @@ -0,0 +1,157 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from datetime import datetime, timezone + +import pytest + +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +def test_write_cloud_event_to_json_with_attributes_only() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "{\'key\': \'value\'}"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_bytes() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=b"test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_str_and_content_type_not_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "text/plain", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "text/plain", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "test"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_str() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="I'm just a string") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "I\'m just a string"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "{\'key\': \'value\'}"}'.encode( + "utf-8" + ) + ) From 8aeed15f7136d2cd761eba8903f1c311085eaf23 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 25 Nov 2024 16:44:52 +0200 Subject: [PATCH 2/2] Implement read method Signed-off-by: Tudor Plugaru --- pyproject.toml | 2 ++ src/cloudevents/core/base.py | 4 +++ src/cloudevents/core/formats/base.py | 2 +- src/cloudevents/core/formats/json.py | 39 ++++++++++++++++++++---- src/cloudevents/core/v1/event.py | 10 +++--- tests/test_core/test_format/test_json.py | 20 ++++++++++++ uv.lock | 38 ++++++++++++++++++++++- 7 files changed, 103 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c152bc0..1f72d939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ keywords = [ ] dependencies = [ "ruff>=0.6.8", + "python-dateutil>=2.8.2", ] [project.urls] @@ -53,6 +54,7 @@ dev-dependencies = [ "flake8-print>=5.0.0", "pre-commit>=3.8.0", "pytest-cov>=5.0.0", + "types-python-dateutil>=2.9.0.20241003", ] [tool.uv.pip] diff --git a/src/cloudevents/core/base.py b/src/cloudevents/core/base.py index 6371d6f1..5b727f1d 100644 --- a/src/cloudevents/core/base.py +++ b/src/cloudevents/core/base.py @@ -18,6 +18,10 @@ class BaseCloudEvent(Protocol): + def __init__( + self, attributes: dict[str, Any], data: Optional[Union[dict, str, bytes]] = None + ) -> None: ... + def get_id(self) -> str: ... def get_source(self) -> str: ... diff --git a/src/cloudevents/core/formats/base.py b/src/cloudevents/core/formats/base.py index 859a684b..6c8c777e 100644 --- a/src/cloudevents/core/formats/base.py +++ b/src/cloudevents/core/formats/base.py @@ -21,4 +21,4 @@ class Format(Protocol): def read(self, data: Union[str, bytes]) -> BaseCloudEvent: ... - def write(self, event: BaseCloudEvent) -> str: ... + def write(self, event: BaseCloudEvent) -> bytes: ... diff --git a/src/cloudevents/core/formats/json.py b/src/cloudevents/core/formats/json.py index 930b0da8..27365554 100644 --- a/src/cloudevents/core/formats/json.py +++ b/src/cloudevents/core/formats/json.py @@ -16,12 +16,16 @@ import base64 import re from datetime import datetime -from json import JSONEncoder, dumps -from typing import Any, Final, Pattern, Union +from json import JSONEncoder, dumps, loads +from typing import Any, Final, Pattern, Type, TypeVar, Union + +from dateutil.parser import isoparse from cloudevents.core.base import BaseCloudEvent from cloudevents.core.formats.base import Format +T = TypeVar("T", bound=BaseCloudEvent) + class _JSONEncoderWithDatetime(JSONEncoder): """ @@ -46,10 +50,33 @@ class JSONFormat(Format): r"^(application|text)\\/([a-zA-Z]+\\+)?json(;.*)*$" ) - def read(self, data: Union[str, bytes]) -> BaseCloudEvent: - pass + def read(self, event_klass: Type[T], data: Union[str, bytes]) -> T: + """ + Read a CloudEvent from a JSON formatted byte string. + + :param data: The JSON formatted byte array. + :return: The CloudEvent instance. + """ + if isinstance(data, bytes): + decoded_data: str = data.decode("utf-8") + else: + decoded_data = data + + event_attributes = loads(decoded_data) + + if "time" in event_attributes: + event_attributes["time"] = isoparse(event_attributes["time"]) + + event_data: Union[str, bytes] = event_attributes.get("data") + if event_data is None: + event_data_base64 = event_attributes.get("data_base64") + if event_data_base64 is not None: + event_data = base64.b64decode(event_data_base64) + + # disable mypy due to https://github.com/python/mypy/issues/9003 + return event_klass(event_attributes, event_data) # type: ignore - def write(self, event: BaseCloudEvent) -> bytes: + def write(self, event: T) -> bytes: """ Write a CloudEvent to a JSON formatted byte string. @@ -57,7 +84,7 @@ def write(self, event: BaseCloudEvent) -> bytes: :return: The CloudEvent as a JSON formatted byte array. """ event_data = event.get_data() - event_dict: dict[str, Any] = {**event.get_attributes()} + event_dict: dict[str, Any] = dict(event.get_attributes()) if event_data is not None: if isinstance(event_data, (bytes, bytearray)): diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index e9d9f874..4f353c39 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -15,7 +15,7 @@ import re from collections import defaultdict from datetime import datetime -from typing import Any, Final, Optional +from typing import Any, Final, Optional, Union from cloudevents.core.base import BaseCloudEvent from cloudevents.core.v1.exceptions import ( @@ -45,7 +45,9 @@ class CloudEvent(BaseCloudEvent): obliged to follow this contract. """ - def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> None: + def __init__( + self, attributes: dict[str, Any], data: Optional[Union[dict, str, bytes]] = None + ) -> None: """ Create a new CloudEvent instance. @@ -57,7 +59,7 @@ def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> N """ self._validate_attribute(attributes=attributes) self._attributes: dict[str, Any] = attributes - self._data: Optional[dict] = data + self._data: Optional[Union[dict, str, bytes]] = data @staticmethod def _validate_attribute(attributes: dict[str, Any]) -> None: @@ -316,7 +318,7 @@ def get_extension(self, extension_name: str) -> Any: """ return self._attributes.get(extension_name) - def get_data(self) -> Optional[dict]: + def get_data(self) -> Optional[Union[dict, str, bytes]]: """ Retrieve data of the event. diff --git a/tests/test_core/test_format/test_json.py b/tests/test_core/test_format/test_json.py index f8ad31f6..3efed6e1 100644 --- a/tests/test_core/test_format/test_json.py +++ b/tests/test_core/test_format/test_json.py @@ -155,3 +155,23 @@ def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_json() - "utf-8" ) ) + + +def test_read_cloud_event_from_json_with_attributes_only() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() is None diff --git a/uv.lock b/uv.lock index ae08d830..4a6a6605 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ name = "cloudevents" version = "2.0.0a1" source = { editable = "." } dependencies = [ + { name = "python-dateutil" }, { name = "ruff" }, ] @@ -28,10 +29,14 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "types-python-dateutil" }, ] [package.metadata] -requires-dist = [{ name = "ruff", specifier = ">=0.6.8" }] +requires-dist = [ + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "ruff", specifier = ">=0.6.8" }, +] [package.metadata.requires-dev] dev = [ @@ -43,6 +48,7 @@ dev = [ { name = "pre-commit", specifier = ">=3.8.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "types-python-dateutil", specifier = ">=2.9.0.20241003" }, ] [[package]] @@ -373,6 +379,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -451,6 +469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 }, ] +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -460,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241003" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f8/f6ee4c803a7beccffee21bb29a71573b39f7037c224843eff53e5308c16e/types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446", size = 9210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/d6/ba5f61958f358028f2e2ba1b8e225b8e263053bd57d3a79e2d2db64c807b/types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d", size = 9693 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"