Skip to content

Commit 403696d

Browse files
committed
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 <[email protected]>
1 parent 4e226a2 commit 403696d

File tree

11 files changed

+58
-57
lines changed

11 files changed

+58
-57
lines changed

examples/src/consumer.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626

2727
from __future__ import annotations
2828

29+
import sys
2930
from dataclasses import dataclass
3031
from datetime import datetime
31-
from typing import Any, Dict
32+
from typing import Any
3233

3334
import requests
3435

@@ -103,7 +104,10 @@ def get_user(self, user_id: int) -> User:
103104
uri = f"{self.base_uri}/users/{user_id}"
104105
response = requests.get(uri, timeout=5)
105106
response.raise_for_status()
106-
data: Dict[str, Any] = response.json()
107+
data: dict[str, Any] = response.json()
108+
# Python < 3.11 don't support ISO 8601 offsets without a colon
109+
if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit():
110+
data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:]
107111
return User(
108112
id=data["id"],
109113
name=data["name"],
@@ -130,7 +134,10 @@ def create_user(
130134
uri = f"{self.base_uri}/users/"
131135
response = requests.post(uri, json={"name": name}, timeout=5)
132136
response.raise_for_status()
133-
data: Dict[str, Any] = response.json()
137+
data: dict[str, Any] = response.json()
138+
# Python < 3.11 don't support ISO 8601 offsets without a colon
139+
if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit():
140+
data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:]
134141
return User(
135142
id=data["id"],
136143
name=data["name"],

examples/src/fastapi.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,32 @@
2828
from __future__ import annotations
2929

3030
import logging
31-
from dataclasses import dataclass
32-
from datetime import UTC, datetime
33-
from typing import Any, Dict
31+
from datetime import datetime, timezone
32+
from typing import Annotated, Any, Dict, Optional
33+
34+
from pydantic import BaseModel, PlainSerializer
3435

3536
from fastapi import FastAPI, HTTPException
3637

3738
app = FastAPI()
3839
logger = logging.getLogger(__name__)
3940

4041

41-
@dataclass()
42-
class User:
42+
class User(BaseModel):
4343
"""User data class."""
4444

4545
id: int
4646
name: str
47-
created_on: datetime
48-
email: str | None
49-
ip_address: str | None
47+
created_on: Annotated[
48+
datetime,
49+
PlainSerializer(
50+
lambda dt: dt.strftime("%Y-%m-%dT%H:%M:%S%z"),
51+
return_type=str,
52+
when_used="json",
53+
),
54+
]
55+
email: Optional[str]
56+
ip_address: Optional[str]
5057
hobbies: list[str]
5158
admin: bool
5259

@@ -120,7 +127,7 @@ async def create_new_user(user: dict[str, Any]) -> User:
120127
FAKE_DB[uid] = User(
121128
id=uid,
122129
name=user["name"],
123-
created_on=datetime.now(tz=UTC),
130+
created_on=datetime.now(tz=timezone.utc),
124131
email=user.get("email"),
125132
ip_address=user.get("ip_address"),
126133
hobbies=user.get("hobbies", []),

examples/src/flask.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
import logging
2323
from dataclasses import dataclass
24-
from datetime import UTC, datetime
24+
from datetime import datetime, timezone
2525
from typing import Any, Dict, Tuple
2626

2727
from flask import Flask, Response, abort, jsonify, request
@@ -73,7 +73,7 @@ def dict(self) -> dict[str, Any]:
7373
return {
7474
"id": self.id,
7575
"name": self.name,
76-
"created_on": self.created_on.isoformat(),
76+
"created_on": self.created_on.strftime("%Y-%m-%dT%H:%M:%S%z"),
7777
"email": self.email,
7878
"ip_address": self.ip_address,
7979
"hobbies": self.hobbies,
@@ -119,7 +119,7 @@ def create_user() -> Response:
119119
FAKE_DB[uid] = User(
120120
id=uid,
121121
name=user["name"],
122-
created_on=datetime.now(tz=UTC),
122+
created_on=datetime.now(tz=timezone.utc),
123123
email=user.get("email"),
124124
ip_address=user.get("ip_address"),
125125
hobbies=user.get("hobbies", []),

examples/tests/test_01_provider_fastapi.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from __future__ import annotations
2626

2727
import time
28-
from datetime import UTC, datetime
28+
from datetime import datetime, timezone
2929
from multiprocessing import Process
3030
from typing import Any, Dict, Generator, Union
3131
from unittest.mock import MagicMock
@@ -132,7 +132,7 @@ def mock_user_123_exists() -> None:
132132
id=123,
133133
name="Verna Hampton",
134134
135-
created_on=datetime.now(tz=UTC),
135+
created_on=datetime.now(tz=timezone.utc),
136136
ip_address="10.1.2.3",
137137
hobbies=["hiking", "swimming"],
138138
admin=False,
@@ -172,7 +172,7 @@ def mock_delete_request_to_delete_user() -> None:
172172
id=123,
173173
name="Verna Hampton",
174174
175-
created_on=datetime.now(tz=UTC),
175+
created_on=datetime.now(tz=timezone.utc),
176176
ip_address="10.1.2.3",
177177
hobbies=["hiking", "swimming"],
178178
admin=False,
@@ -181,7 +181,7 @@ def mock_delete_request_to_delete_user() -> None:
181181
id=124,
182182
name="Jane Doe",
183183
184-
created_on=datetime.now(tz=UTC),
184+
created_on=datetime.now(tz=timezone.utc),
185185
ip_address="10.1.2.5",
186186
hobbies=["running", "dancing"],
187187
admin=False,

examples/tests/test_01_provider_flask.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from __future__ import annotations
2626

2727
import time
28-
from datetime import UTC, datetime
28+
from datetime import datetime, timezone
2929
from multiprocessing import Process
3030
from typing import Any, Dict, Generator, Union
3131
from unittest.mock import MagicMock
@@ -126,7 +126,7 @@ def mock_user_123_exists() -> None:
126126
id=123,
127127
name="Verna Hampton",
128128
129-
created_on=datetime.now(tz=UTC),
129+
created_on=datetime.now(tz=timezone.utc),
130130
ip_address="10.1.2.3",
131131
hobbies=["hiking", "swimming"],
132132
admin=False,
@@ -165,7 +165,7 @@ def mock_delete_request_to_delete_user() -> None:
165165
id=123,
166166
name="Verna Hampton",
167167
168-
created_on=datetime.now(tz=UTC),
168+
created_on=datetime.now(tz=timezone.utc),
169169
ip_address="10.1.2.3",
170170
hobbies=["hiking", "swimming"],
171171
admin=False,
@@ -174,7 +174,7 @@ def mock_delete_request_to_delete_user() -> None:
174174
id=124,
175175
name="Jane Doe",
176176
177-
created_on=datetime.now(tz=UTC),
177+
created_on=datetime.now(tz=timezone.utc),
178178
ip_address="10.1.2.5",
179179
hobbies=["running", "dancing"],
180180
admin=False,

examples/tests/v3/test_00_consumer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def test_get_existing_user(pact: Pact) -> None:
7878
"created_on": match.datetime(
7979
# Python datetime objects are automatically formatted
8080
datetime.now(tz=timezone.utc),
81-
format="%Y-%m-%dT%H:%M:%S.%fZ",
81+
format="%Y-%m-%dT%H:%M:%S%z",
8282
),
8383
}
8484
(
@@ -142,7 +142,7 @@ def test_create_user(pact: Pact) -> None:
142142
"created_on": match.datetime(
143143
# Python datetime objects are automatically formatted
144144
datetime.now(tz=timezone.utc),
145-
format="%Y-%m-%dT%H:%M:%S.%fZ",
145+
format="%Y-%m-%dT%H:%M:%S%z",
146146
),
147147
}
148148

examples/tests/v3/test_01_fastapi_provider.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from __future__ import annotations
2828

2929
import time
30-
from datetime import UTC, datetime
30+
from datetime import datetime, timezone
3131
from multiprocessing import Process
3232
from typing import TYPE_CHECKING, Callable, Dict, Literal
3333
from unittest.mock import MagicMock
@@ -200,7 +200,7 @@ def mock_user_exists() -> None:
200200
id=123,
201201
name="Verna Hampton",
202202
203-
created_on=datetime.now(tz=UTC),
203+
created_on=datetime.now(tz=timezone.utc),
204204
ip_address="10.1.2.3",
205205
hobbies=["hiking", "swimming"],
206206
admin=False,
@@ -256,7 +256,7 @@ def mock_delete_request_to_delete_user() -> None:
256256
id=123,
257257
name="Verna Hampton",
258258
259-
created_on=datetime.now(tz=UTC),
259+
created_on=datetime.now(tz=timezone.utc),
260260
ip_address="10.1.2.3",
261261
hobbies=["hiking", "swimming"],
262262
admin=False,
@@ -265,7 +265,7 @@ def mock_delete_request_to_delete_user() -> None:
265265
id=124,
266266
name="Jane Doe",
267267
268-
created_on=datetime.now(tz=UTC),
268+
created_on=datetime.now(tz=timezone.utc),
269269
ip_address="10.1.2.5",
270270
hobbies=["running", "dancing"],
271271
admin=False,

src/pact/matchers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,6 @@ class Regexes(Enum):
484484
r'[12]\d{2}|3([0-5]\d|6[1-6])))?)'
485485
time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$'
486486
iso_8601_datetime = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \
487-
r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|\x5A)?$'
487+
r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:?\d\d)|\x5A)?$'
488488
iso_8601_datetime_ms = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \
489-
r'[0-6]\d\.\d+(?:(?:[+-]\d\d:\d\d)|\x5A)?$'
489+
r'[0-6]\d\.\d+(?:(?:[+-]\d\d:?\d\d)|\x5A)?$'

src/pact/v3/generate/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,15 +300,15 @@ def datetime(
300300
[`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format].
301301
302302
If not provided, an ISO 8601 timestamp format will be used:
303-
`%Y-%m-%dT%H:%M:%S`.
303+
`%Y-%m-%dT%H:%M:%S%z`.
304304
disable_conversion:
305305
If True, the conversion from Python's `strftime` format to Java's
306306
`SimpleDateFormat` format will be disabled, and the format must be
307307
in Java's `SimpleDateFormat` format. As a result, the value must be
308308
"""
309309
if not disable_conversion:
310-
format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S")
311-
return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ss"})
310+
format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S%z")
311+
return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ssZ"})
312312

313313

314314
def timestamp(

src/pact/v3/match/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ def datetime(
656656
[`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format].
657657
658658
If not provided, an ISO 8601 timestamp format will be used:
659-
`%Y-%m-%dT%H:%M:%S`.
659+
`%Y-%m-%dT%H:%M:%S%z`.
660660
disable_conversion:
661661
If True, the conversion from Python's `strftime` format to Java's
662662
`SimpleDateFormat` format will be disabled, and the format must be
@@ -667,7 +667,7 @@ def datetime(
667667
if not isinstance(value, builtins.str):
668668
msg = "When disable_conversion is True, the value must be a string."
669669
raise ValueError(msg)
670-
format = format or "yyyy-MM-dd'T'HH:mm:ss"
670+
format = format or "yyyy-MM-dd'T'HH:mm:ssZ"
671671
if value is UNSET:
672672
return GenericMatcher(
673673
"timestamp",
@@ -679,7 +679,7 @@ def datetime(
679679
value=value,
680680
format=format,
681681
)
682-
format = format or "%Y-%m-%dT%H:%M:%S"
682+
format = format or "%Y-%m-%dT%H:%M:%S.%f%z"
683683
if isinstance(value, dt.datetime):
684684
value = value.strftime(format)
685685
format = strftime_to_simple_date_format(format)

0 commit comments

Comments
 (0)