From 1c55bb902495a184e6b77076c55f71cf2a033162 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 8 Oct 2024 10:54:18 +1100 Subject: [PATCH] fix: ISO 8601 incompatibility There were some inconsistencies in the handling of ISO 8601 datetime values across Python versions and Pact implementations. Signed-off-by: JP-Ellis --- examples/src/consumer.py | 13 ++++++++++--- examples/src/fastapi.py | 25 ++++++++++++++++--------- examples/src/flask.py | 2 +- examples/tests/v3/test_00_consumer.py | 4 ++-- src/pact/matchers.py | 4 ++-- src/pact/v3/generate/__init__.py | 6 +++--- src/pact/v3/match/__init__.py | 6 +++--- 7 files changed, 37 insertions(+), 23 deletions(-) diff --git a/examples/src/consumer.py b/examples/src/consumer.py index 453297923..9986db9cd 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -26,9 +26,10 @@ from __future__ import annotations +import sys from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict +from typing import Any import requests @@ -103,7 +104,10 @@ def get_user(self, user_id: int) -> User: uri = f"{self.base_uri}/users/{user_id}" response = requests.get(uri, timeout=5) response.raise_for_status() - data: Dict[str, Any] = response.json() + data: dict[str, Any] = response.json() + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] return User( id=data["id"], name=data["name"], @@ -130,7 +134,10 @@ def create_user( uri = f"{self.base_uri}/users/" response = requests.post(uri, json={"name": name}, timeout=5) response.raise_for_status() - data: Dict[str, Any] = response.json() + data: dict[str, Any] = response.json() + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] return User( id=data["id"], name=data["name"], diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index 013e95f13..91558a8df 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -28,9 +28,10 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import Any, Dict +from datetime import datetime, timezone +from typing import Annotated, Any, Dict, Optional + +from pydantic import BaseModel, PlainSerializer from fastapi import FastAPI, HTTPException @@ -38,15 +39,21 @@ logger = logging.getLogger(__name__) -@dataclass() -class User: +class User(BaseModel): """User data class.""" id: int name: str - created_on: datetime - email: str | None - ip_address: str | None + created_on: Annotated[ + datetime, + PlainSerializer( + lambda dt: dt.strftime("%Y-%m-%dT%H:%M:%S%z"), + return_type=str, + when_used="json", + ), + ] + email: Optional[str] + ip_address: Optional[str] hobbies: list[str] admin: bool @@ -120,7 +127,7 @@ async def create_new_user(user: dict[str, Any]) -> User: FAKE_DB[uid] = User( id=uid, name=user["name"], - created_on=datetime.now(tz=UTC), + created_on=datetime.now(tz=timezone.utc), email=user.get("email"), ip_address=user.get("ip_address"), hobbies=user.get("hobbies", []), diff --git a/examples/src/flask.py b/examples/src/flask.py index 98107e3c1..e63309911 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -73,7 +73,7 @@ def dict(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, - "created_on": self.created_on.isoformat(), + "created_on": self.created_on.strftime("%Y-%m-%dT%H:%M:%S%z"), "email": self.email, "ip_address": self.ip_address, "hobbies": self.hobbies, diff --git a/examples/tests/v3/test_00_consumer.py b/examples/tests/v3/test_00_consumer.py index 4ee05cfb8..523d55d25 100644 --- a/examples/tests/v3/test_00_consumer.py +++ b/examples/tests/v3/test_00_consumer.py @@ -78,7 +78,7 @@ def test_get_existing_user(pact: Pact) -> None: "created_on": match.datetime( # Python datetime objects are automatically formatted datetime.now(tz=timezone.utc), - format="%Y-%m-%dT%H:%M:%S.%fZ", + format="%Y-%m-%dT%H:%M:%S%z", ), } ( @@ -142,7 +142,7 @@ def test_create_user(pact: Pact) -> None: "created_on": match.datetime( # Python datetime objects are automatically formatted datetime.now(tz=timezone.utc), - format="%Y-%m-%dT%H:%M:%S.%fZ", + format="%Y-%m-%dT%H:%M:%S%z", ), } diff --git a/src/pact/matchers.py b/src/pact/matchers.py index 48bedad23..b41972327 100644 --- a/src/pact/matchers.py +++ b/src/pact/matchers.py @@ -484,6 +484,6 @@ class Regexes(Enum): r'[12]\d{2}|3([0-5]\d|6[1-6])))?)' time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$' iso_8601_datetime = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ - r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|\x5A)?$' + r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:?\d\d)|\x5A)?$' iso_8601_datetime_ms = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ - r'[0-6]\d\.\d+(?:(?:[+-]\d\d:\d\d)|\x5A)?$' + r'[0-6]\d\.\d+(?:(?:[+-]\d\d:?\d\d)|\x5A)?$' diff --git a/src/pact/v3/generate/__init__.py b/src/pact/v3/generate/__init__.py index 9c1f6d6c7..fb1ece3d6 100644 --- a/src/pact/v3/generate/__init__.py +++ b/src/pact/v3/generate/__init__.py @@ -300,15 +300,15 @@ def datetime( [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. If not provided, an ISO 8601 timestamp format will be used: - `%Y-%m-%dT%H:%M:%S`. + `%Y-%m-%dT%H:%M:%S%z`. disable_conversion: If True, the conversion from Python's `strftime` format to Java's `SimpleDateFormat` format will be disabled, and the format must be in Java's `SimpleDateFormat` format. As a result, the value must be """ if not disable_conversion: - format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S") - return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ss"}) + format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S%z") + return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ssZ"}) def timestamp( diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 722092989..43c9446fa 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -656,7 +656,7 @@ def datetime( [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. If not provided, an ISO 8601 timestamp format will be used: - `%Y-%m-%dT%H:%M:%S`. + `%Y-%m-%dT%H:%M:%S%z`. disable_conversion: If True, the conversion from Python's `strftime` format to Java's `SimpleDateFormat` format will be disabled, and the format must be @@ -667,7 +667,7 @@ def datetime( if not isinstance(value, builtins.str): msg = "When disable_conversion is True, the value must be a string." raise ValueError(msg) - format = format or "yyyy-MM-dd'T'HH:mm:ss" + format = format or "yyyy-MM-dd'T'HH:mm:ssZ" if value is UNSET: return GenericMatcher( "timestamp", @@ -679,7 +679,7 @@ def datetime( value=value, format=format, ) - format = format or "%Y-%m-%dT%H:%M:%S" + format = format or "%Y-%m-%dT%H:%M:%S.%f%z" if isinstance(value, dt.datetime): value = value.strftime(format) format = strftime_to_simple_date_format(format)