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 4e226a2 commit 403696d
Show file tree
Hide file tree
Showing 11 changed files with 58 additions and 57 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
6 changes: 3 additions & 3 deletions examples/src/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any, Dict, Tuple

from flask import Flask, Response, abort, jsonify, request
Expand Down 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 Expand Up @@ -119,7 +119,7 @@ def create_user() -> Response:
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
8 changes: 4 additions & 4 deletions examples/tests/test_01_provider_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from __future__ import annotations

import time
from datetime import UTC, datetime
from datetime import datetime, timezone
from multiprocessing import Process
from typing import Any, Dict, Generator, Union
from unittest.mock import MagicMock
Expand Down Expand Up @@ -132,7 +132,7 @@ def mock_user_123_exists() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand Down Expand Up @@ -172,7 +172,7 @@ def mock_delete_request_to_delete_user() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand All @@ -181,7 +181,7 @@ def mock_delete_request_to_delete_user() -> None:
id=124,
name="Jane Doe",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.5",
hobbies=["running", "dancing"],
admin=False,
Expand Down
8 changes: 4 additions & 4 deletions examples/tests/test_01_provider_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from __future__ import annotations

import time
from datetime import UTC, datetime
from datetime import datetime, timezone
from multiprocessing import Process
from typing import Any, Dict, Generator, Union
from unittest.mock import MagicMock
Expand Down Expand Up @@ -126,7 +126,7 @@ def mock_user_123_exists() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand Down Expand Up @@ -165,7 +165,7 @@ def mock_delete_request_to_delete_user() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand All @@ -174,7 +174,7 @@ def mock_delete_request_to_delete_user() -> None:
id=124,
name="Jane Doe",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.5",
hobbies=["running", "dancing"],
admin=False,
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
8 changes: 4 additions & 4 deletions examples/tests/v3/test_01_fastapi_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from __future__ import annotations

import time
from datetime import UTC, datetime
from datetime import datetime, timezone
from multiprocessing import Process
from typing import TYPE_CHECKING, Callable, Dict, Literal
from unittest.mock import MagicMock
Expand Down Expand Up @@ -200,7 +200,7 @@ def mock_user_exists() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand Down Expand Up @@ -256,7 +256,7 @@ def mock_delete_request_to_delete_user() -> None:
id=123,
name="Verna Hampton",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.3",
hobbies=["hiking", "swimming"],
admin=False,
Expand All @@ -265,7 +265,7 @@ def mock_delete_request_to_delete_user() -> None:
id=124,
name="Jane Doe",
email="[email protected]",
created_on=datetime.now(tz=UTC),
created_on=datetime.now(tz=timezone.utc),
ip_address="10.1.2.5",
hobbies=["running", "dancing"],
admin=False,
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"})


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"
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
27 changes: 7 additions & 20 deletions tests/v3/compatibility_suite/util/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import time
import warnings
from contextvars import ContextVar
from datetime import datetime
from datetime import datetime, timezone
from threading import Thread
from typing import TYPE_CHECKING, Any, NoReturn

Expand Down Expand Up @@ -64,13 +64,6 @@

from pact.v3.verifier import Verifier

if sys.version_info < (3, 11):
from datetime import timezone

UTC = timezone.utc
else:
from datetime import UTC


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -239,10 +232,8 @@ def callback() -> tuple[str, int] | str:
msg = "State not found"
raise ValueError(msg)

json_file = (
self.provider_dir
/ f"callback.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
)
timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f")
json_file = self.provider_dir / f"callback.{timestamp}.json"
with json_file.open("w") as f:
json.dump(
{
Expand Down Expand Up @@ -276,10 +267,8 @@ def log_request(response: flask.Response) -> flask.Response:
logger.debug("-> Body: %s", truncate(request.get_data().decode("utf-8")))
logger.debug("-> Form: %s", serialize(request.form))

with (
self.provider_dir
/ f"request.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
).open("w") as f:
timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f")
with (self.provider_dir / f"request.{timestamp}.json").open("w") as f:
json.dump(
{
"method": request.method,
Expand All @@ -306,10 +295,8 @@ def log_response(response: flask.Response) -> flask.Response:
),
)

with (
self.provider_dir
/ f"response.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
).open("w") as f:
timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f")
with (self.provider_dir / f"response.{timestamp}.json").open("w") as f:
json.dump(
{
"status_code": response.status_code,
Expand Down

0 comments on commit 403696d

Please sign in to comment.