Skip to content

Commit

Permalink
fix: ISO 8601 incompatibility
Browse files Browse the repository at this point in the history
There were some inconsistencies in the handling of ISO 8601 datetime
values across Python versions and Pact implementations.

Signed-off-by: JP-Ellis <[email protected]>
  • Loading branch information
JP-Ellis committed Oct 9, 2024
1 parent 1c5c5aa commit 1c55bb9
Show file tree
Hide file tree
Showing 7 changed files with 37 additions and 23 deletions.
13 changes: 10 additions & 3 deletions examples/src/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down
25 changes: 16 additions & 9 deletions examples/src/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,32 @@
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

app = FastAPI()
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

Expand Down Expand Up @@ -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", []),
Expand Down
2 changes: 1 addition & 1 deletion examples/src/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions examples/tests/v3/test_00_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}
(
Expand Down Expand Up @@ -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",
),
}

Expand Down
4 changes: 2 additions & 2 deletions src/pact/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)?$'
6 changes: 3 additions & 3 deletions src/pact/v3/generate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Check warning on line 311 in src/pact/v3/generate/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/pact/v3/generate/__init__.py#L310-L311

Added lines #L310 - L311 were not covered by tests


def timestamp(
Expand Down
6 changes: 3 additions & 3 deletions src/pact/v3/match/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Check warning on line 670 in src/pact/v3/match/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/pact/v3/match/__init__.py#L670

Added line #L670 was not covered by tests
if value is UNSET:
return GenericMatcher(
"timestamp",
Expand All @@ -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)
Expand Down

0 comments on commit 1c55bb9

Please sign in to comment.