Skip to content

Commit 6df45e9

Browse files
authored
feat: replace python-dateutil with stdlib datetime.fromisoformat (#1429)
## Summary Drop the `python-dateutil` dependency from both the generator and all generated client packages. Date/datetime parsing now uses Python's built-in `datetime.fromisoformat()` instead of `dateutil.parser.isoparse()`. This removes one runtime dependency from every generated client, reducing install size and eliminating a dependency that is [unmaintained upstream and being deprecated by Fedora 45](https://fedoraproject.org/wiki/Changes/DeprecatePython-dateutil) (no releases since March 2024). ## Changes **Generator source** (`openapi_python_client/parser/properties/`): - `datetime.py`: Validate defaults with `datetime.datetime.fromisoformat()` instead of `isoparse()`. Normalize `Z` to `+00:00` at generation time so the emitted Python code is clean. - `date.py`: Validate defaults with `datetime.date.fromisoformat()` instead of `isoparse().date()`. - Both: Remove `"from dateutil.parser import isoparse"` from the import set returned by `get_imports()`. **Jinja templates** (`openapi_python_client/templates/property_templates/`): - `datetime_property.py.jinja`: `isoparse(x)` -> `datetime.datetime.fromisoformat(x.replace("Z", "+00:00"))` - `date_property.py.jinja`: `isoparse(x).date()` -> `datetime.date.fromisoformat(x)` **Dependency removal** (all 4 metadata formats): - `pyproject_uv.toml.jinja`, `pyproject_poetry.toml.jinja`, `pyproject_pdm.toml.jinja`, `setup.py.jinja`: Remove `python-dateutil` from generated dependencies. - `pyproject.toml` (generator): Remove `python-dateutil` from runtime deps and `types-python-dateutil` from dev deps. - `integration-tests/pyproject.toml`: Remove both as well. **Tests & golden records**: All regenerated. Unit tests pass (283 passed, 4 skipped). ## Python 3.10 compatibility `datetime.fromisoformat()` gained full ISO 8601 support (including the `Z` suffix) in Python 3.11. On Python 3.10, the `Z` suffix raises a `ValueError`: ```python # Python 3.10 >>> datetime.datetime.fromisoformat("2024-01-15T10:30:00Z") ValueError: Invalid isoformat string: '2024-01-15T10:30:00Z' >>> datetime.datetime.fromisoformat("2024-01-15T10:30:00+00:00") datetime.datetime(2024, 1, 15, 10, 30, tzinfo=datetime.timezone.utc) # works ``` ```python # Python 3.11+ >>> datetime.datetime.fromisoformat("2024-01-15T10:30:00Z") datetime.datetime(2024, 1, 15, 10, 30, tzinfo=datetime.timezone.utc) # works natively ``` The generated datetime parsing code uses `.replace("Z", "+00:00")` to normalize `Z` to an explicit UTC offset before calling `fromisoformat()`, which works on both 3.10 and 3.11+. This is a no-op on strings without `Z`. Date parsing does not need this since date strings have no timezone component. Default values in OpenAPI specs are normalized at generation time (Z -> +00:00), so the emitted default expressions are clean `datetime.datetime.fromisoformat("...")` calls.
1 parent eeda8c5 commit 6df45e9

38 files changed

Lines changed: 616 additions & 748 deletions

end_to_end_tests/docstrings-on-attributes-golden-record/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ include = ["my_test_api_client/py.typed"]
1313
python = "^3.10"
1414
httpx = ">=0.23.0,<0.29.0"
1515
attrs = ">=22.2.0"
16-
python-dateutil = "^2.8.0"
1716

1817
[build-system]
1918
requires = ["poetry-core>=2.0.0,<3.0.0"]

end_to_end_tests/golden-record/my_test_api_client/api/defaults/defaults_tests_defaults_post.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from typing import Any
44

55
import httpx
6-
from dateutil.parser import isoparse
76

87
from ... import errors
98
from ...client import AuthenticatedClient, Client
@@ -17,7 +16,7 @@ def _get_kwargs(
1716
*,
1817
string_prop: str = "the default string",
1918
string_with_num: str = "1",
20-
date_prop: datetime.date = isoparse("1010-10-10").date(),
19+
date_prop: datetime.date = datetime.date.fromisoformat("1010-10-10"),
2120
float_prop: float = 3.14,
2221
float_with_int: float = 3.0,
2322
int_prop: int = 7,
@@ -121,7 +120,7 @@ def sync_detailed(
121120
client: AuthenticatedClient | Client,
122121
string_prop: str = "the default string",
123122
string_with_num: str = "1",
124-
date_prop: datetime.date = isoparse("1010-10-10").date(),
123+
date_prop: datetime.date = datetime.date.fromisoformat("1010-10-10"),
125124
float_prop: float = 3.14,
126125
float_with_int: float = 3.0,
127126
int_prop: int = 7,
@@ -138,7 +137,7 @@ def sync_detailed(
138137
Args:
139138
string_prop (str): Default: 'the default string'.
140139
string_with_num (str): Default: '1'.
141-
date_prop (datetime.date): Default: isoparse('1010-10-10').date().
140+
date_prop (datetime.date): Default: datetime.date.fromisoformat('1010-10-10').
142141
float_prop (float): Default: 3.14.
143142
float_with_int (float): Default: 3.0.
144143
int_prop (int): Default: 7.
@@ -186,7 +185,7 @@ def sync(
186185
client: AuthenticatedClient | Client,
187186
string_prop: str = "the default string",
188187
string_with_num: str = "1",
189-
date_prop: datetime.date = isoparse("1010-10-10").date(),
188+
date_prop: datetime.date = datetime.date.fromisoformat("1010-10-10"),
190189
float_prop: float = 3.14,
191190
float_with_int: float = 3.0,
192191
int_prop: int = 7,
@@ -203,7 +202,7 @@ def sync(
203202
Args:
204203
string_prop (str): Default: 'the default string'.
205204
string_with_num (str): Default: '1'.
206-
date_prop (datetime.date): Default: isoparse('1010-10-10').date().
205+
date_prop (datetime.date): Default: datetime.date.fromisoformat('1010-10-10').
207206
float_prop (float): Default: 3.14.
208207
float_with_int (float): Default: 3.0.
209208
int_prop (int): Default: 7.
@@ -246,7 +245,7 @@ async def asyncio_detailed(
246245
client: AuthenticatedClient | Client,
247246
string_prop: str = "the default string",
248247
string_with_num: str = "1",
249-
date_prop: datetime.date = isoparse("1010-10-10").date(),
248+
date_prop: datetime.date = datetime.date.fromisoformat("1010-10-10"),
250249
float_prop: float = 3.14,
251250
float_with_int: float = 3.0,
252251
int_prop: int = 7,
@@ -263,7 +262,7 @@ async def asyncio_detailed(
263262
Args:
264263
string_prop (str): Default: 'the default string'.
265264
string_with_num (str): Default: '1'.
266-
date_prop (datetime.date): Default: isoparse('1010-10-10').date().
265+
date_prop (datetime.date): Default: datetime.date.fromisoformat('1010-10-10').
267266
float_prop (float): Default: 3.14.
268267
float_with_int (float): Default: 3.0.
269268
int_prop (int): Default: 7.
@@ -309,7 +308,7 @@ async def asyncio(
309308
client: AuthenticatedClient | Client,
310309
string_prop: str = "the default string",
311310
string_with_num: str = "1",
312-
date_prop: datetime.date = isoparse("1010-10-10").date(),
311+
date_prop: datetime.date = datetime.date.fromisoformat("1010-10-10"),
313312
float_prop: float = 3.14,
314313
float_with_int: float = 3.0,
315314
int_prop: int = 7,
@@ -326,7 +325,7 @@ async def asyncio(
326325
Args:
327326
string_prop (str): Default: 'the default string'.
328327
string_with_num (str): Default: '1'.
329-
date_prop (datetime.date): Default: isoparse('1010-10-10').date().
328+
date_prop (datetime.date): Default: datetime.date.fromisoformat('1010-10-10').
330329
float_prop (float): Default: 3.14.
331330
float_with_int (float): Default: 3.0.
332331
int_prop (int): Default: 7.

end_to_end_tests/golden-record/my_test_api_client/models/a_model.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from uuid import UUID
77

88
from attrs import define as _attrs_define
9-
from dateutil.parser import isoparse
109

1110
from ..models.an_all_of_enum import AnAllOfEnum
1211
from ..models.an_enum import AnEnum
@@ -269,28 +268,28 @@ def _parse_a_camel_date_time(data: object) -> datetime.date | datetime.datetime:
269268
try:
270269
if not isinstance(data, str):
271270
raise TypeError()
272-
a_camel_date_time_type_0 = isoparse(data)
271+
a_camel_date_time_type_0 = datetime.datetime.fromisoformat(data.replace("Z", "+00:00"))
273272

274273
return a_camel_date_time_type_0
275274
except (TypeError, ValueError, AttributeError, KeyError):
276275
pass
277276
if not isinstance(data, str):
278277
raise TypeError()
279-
a_camel_date_time_type_1 = isoparse(data).date()
278+
a_camel_date_time_type_1 = datetime.date.fromisoformat(data)
280279

281280
return a_camel_date_time_type_1
282281

283282
a_camel_date_time = _parse_a_camel_date_time(d.pop("aCamelDateTime"))
284283

285-
a_date = isoparse(d.pop("a_date")).date()
284+
a_date = datetime.date.fromisoformat(d.pop("a_date"))
286285

287286
def _parse_a_nullable_date(data: object) -> datetime.date | None:
288287
if data is None:
289288
return data
290289
try:
291290
if not isinstance(data, str):
292291
raise TypeError()
293-
a_nullable_date_type_0 = isoparse(data).date()
292+
a_nullable_date_type_0 = datetime.date.fromisoformat(data)
294293

295294
return a_nullable_date_type_0
296295
except (TypeError, ValueError, AttributeError, KeyError):
@@ -414,7 +413,7 @@ def _parse_nullable_model(data: object) -> ModelWithUnionProperty | None:
414413
if isinstance(_a_not_required_date, Unset):
415414
a_not_required_date = UNSET
416415
else:
417-
a_not_required_date = isoparse(_a_not_required_date).date()
416+
a_not_required_date = datetime.date.fromisoformat(_a_not_required_date)
418417

419418
_a_not_required_uuid = d.pop("a_not_required_uuid", UNSET)
420419
a_not_required_uuid: UUID | Unset

end_to_end_tests/golden-record/my_test_api_client/models/a_model_with_properties_reference_that_are_not_object.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from attrs import define as _attrs_define
99
from attrs import field as _attrs_field
10-
from dateutil.parser import isoparse
1110

1211
from ..models.an_enum import AnEnum
1312
from ..types import File
@@ -230,17 +229,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
230229
date_properties_ref = []
231230
_date_properties_ref = d.pop("date_properties_ref")
232231
for componentsschemas_an_other_array_of_date_item_data in _date_properties_ref:
233-
componentsschemas_an_other_array_of_date_item = isoparse(
232+
componentsschemas_an_other_array_of_date_item = datetime.date.fromisoformat(
234233
componentsschemas_an_other_array_of_date_item_data
235-
).date()
234+
)
236235

237236
date_properties_ref.append(componentsschemas_an_other_array_of_date_item)
238237

239238
datetime_properties_ref = []
240239
_datetime_properties_ref = d.pop("datetime_properties_ref")
241240
for componentsschemas_an_other_array_of_date_time_item_data in _datetime_properties_ref:
242-
componentsschemas_an_other_array_of_date_time_item = isoparse(
243-
componentsschemas_an_other_array_of_date_time_item_data
241+
componentsschemas_an_other_array_of_date_time_item = datetime.datetime.fromisoformat(
242+
componentsschemas_an_other_array_of_date_time_item_data.replace("Z", "+00:00")
244243
)
245244

246245
datetime_properties_ref.append(componentsschemas_an_other_array_of_date_time_item)
@@ -276,14 +275,18 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
276275
date_properties = []
277276
_date_properties = d.pop("date_properties")
278277
for componentsschemas_an_array_of_date_item_data in _date_properties:
279-
componentsschemas_an_array_of_date_item = isoparse(componentsschemas_an_array_of_date_item_data).date()
278+
componentsschemas_an_array_of_date_item = datetime.date.fromisoformat(
279+
componentsschemas_an_array_of_date_item_data
280+
)
280281

281282
date_properties.append(componentsschemas_an_array_of_date_item)
282283

283284
datetime_properties = []
284285
_datetime_properties = d.pop("datetime_properties")
285286
for componentsschemas_an_array_of_date_time_item_data in _datetime_properties:
286-
componentsschemas_an_array_of_date_time_item = isoparse(componentsschemas_an_array_of_date_time_item_data)
287+
componentsschemas_an_array_of_date_time_item = datetime.datetime.fromisoformat(
288+
componentsschemas_an_array_of_date_time_item_data.replace("Z", "+00:00")
289+
)
287290

288291
datetime_properties.append(componentsschemas_an_array_of_date_time_item)
289292

@@ -310,9 +313,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
310313

311314
str_property_ref = d.pop("str_property_ref")
312315

313-
date_property_ref = isoparse(d.pop("date_property_ref")).date()
316+
date_property_ref = datetime.date.fromisoformat(d.pop("date_property_ref"))
314317

315-
datetime_property_ref = isoparse(d.pop("datetime_property_ref"))
318+
datetime_property_ref = datetime.datetime.fromisoformat(d.pop("datetime_property_ref").replace("Z", "+00:00"))
316319

317320
int32_property_ref = d.pop("int32_property_ref")
318321

end_to_end_tests/golden-record/my_test_api_client/models/body_upload_file_tests_upload_post.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from attrs import define as _attrs_define
1010
from attrs import field as _attrs_field
11-
from dateutil.parser import isoparse
1211

1312
from .. import types
1413
from ..models.different_enum import DifferentEnum
@@ -300,14 +299,14 @@ def _parse_some_nullable_object(data: object) -> BodyUploadFileTestsUploadPostSo
300299
if isinstance(_a_datetime, Unset):
301300
a_datetime = UNSET
302301
else:
303-
a_datetime = isoparse(_a_datetime)
302+
a_datetime = datetime.datetime.fromisoformat(_a_datetime.replace("Z", "+00:00"))
304303

305304
_a_date = d.pop("a_date", UNSET)
306305
a_date: datetime.date | Unset
307306
if isinstance(_a_date, Unset):
308307
a_date = UNSET
309308
else:
310-
a_date = isoparse(_a_date).date()
309+
a_date = datetime.date.fromisoformat(_a_date)
311310

312311
some_number = d.pop("some_number", UNSET)
313312

end_to_end_tests/golden-record/my_test_api_client/models/extended.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from attrs import define as _attrs_define
99
from attrs import field as _attrs_field
10-
from dateutil.parser import isoparse
1110

1211
from ..models.an_all_of_enum import AnAllOfEnum
1312
from ..models.an_enum import AnEnum
@@ -276,28 +275,28 @@ def _parse_a_camel_date_time(data: object) -> datetime.date | datetime.datetime:
276275
try:
277276
if not isinstance(data, str):
278277
raise TypeError()
279-
a_camel_date_time_type_0 = isoparse(data)
278+
a_camel_date_time_type_0 = datetime.datetime.fromisoformat(data.replace("Z", "+00:00"))
280279

281280
return a_camel_date_time_type_0
282281
except (TypeError, ValueError, AttributeError, KeyError):
283282
pass
284283
if not isinstance(data, str):
285284
raise TypeError()
286-
a_camel_date_time_type_1 = isoparse(data).date()
285+
a_camel_date_time_type_1 = datetime.date.fromisoformat(data)
287286

288287
return a_camel_date_time_type_1
289288

290289
a_camel_date_time = _parse_a_camel_date_time(d.pop("aCamelDateTime"))
291290

292-
a_date = isoparse(d.pop("a_date")).date()
291+
a_date = datetime.date.fromisoformat(d.pop("a_date"))
293292

294293
def _parse_a_nullable_date(data: object) -> datetime.date | None:
295294
if data is None:
296295
return data
297296
try:
298297
if not isinstance(data, str):
299298
raise TypeError()
300-
a_nullable_date_type_0 = isoparse(data).date()
299+
a_nullable_date_type_0 = datetime.date.fromisoformat(data)
301300

302301
return a_nullable_date_type_0
303302
except (TypeError, ValueError, AttributeError, KeyError):
@@ -421,7 +420,7 @@ def _parse_nullable_model(data: object) -> ModelWithUnionProperty | None:
421420
if isinstance(_a_not_required_date, Unset):
422421
a_not_required_date = UNSET
423422
else:
424-
a_not_required_date = isoparse(_a_not_required_date).date()
423+
a_not_required_date = datetime.date.fromisoformat(_a_not_required_date)
425424

426425
_a_not_required_uuid = d.pop("a_not_required_uuid", UNSET)
427426
a_not_required_uuid: UUID | Unset

end_to_end_tests/golden-record/my_test_api_client/models/model_with_date_time_property.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from attrs import define as _attrs_define
88
from attrs import field as _attrs_field
9-
from dateutil.parser import isoparse
109

1110
from ..types import UNSET, Unset
1211

@@ -44,7 +43,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
4443
if isinstance(_datetime_, Unset):
4544
datetime_ = UNSET
4645
else:
47-
datetime_ = isoparse(_datetime_)
46+
datetime_ = datetime.datetime.fromisoformat(_datetime_.replace("Z", "+00:00"))
4847

4948
model_with_date_time_property = cls(
5049
datetime_=datetime_,

end_to_end_tests/golden-record/my_test_api_client/models/model_with_merged_properties.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from attrs import define as _attrs_define
88
from attrs import field as _attrs_field
9-
from dateutil.parser import isoparse
109

1110
from ..models.model_with_merged_properties_string_to_enum import ModelWithMergedPropertiesStringToEnum
1211
from ..types import UNSET, Unset
@@ -81,7 +80,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
8180
if isinstance(_string_to_date, Unset):
8281
string_to_date = UNSET
8382
else:
84-
string_to_date = isoparse(_string_to_date).date()
83+
string_to_date = datetime.date.fromisoformat(_string_to_date)
8584

8685
number_to_int = d.pop("numberToInt", UNSET)
8786

end_to_end_tests/golden-record/my_test_api_client/models/model_with_primitive_additional_properties_a_date_holder.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from attrs import define as _attrs_define
88
from attrs import field as _attrs_field
9-
from dateutil.parser import isoparse
109

1110
T = TypeVar("T", bound="ModelWithPrimitiveAdditionalPropertiesADateHolder")
1211

@@ -32,7 +31,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
3231

3332
additional_properties = {}
3433
for prop_name, prop_dict in d.items():
35-
additional_property = isoparse(prop_dict)
34+
additional_property = datetime.datetime.fromisoformat(prop_dict.replace("Z", "+00:00"))
3635

3736
additional_properties[prop_name] = additional_property
3837

end_to_end_tests/golden-record/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ include = ["my_test_api_client/py.typed"]
1313
python = "^3.10"
1414
httpx = ">=0.23.0,<0.29.0"
1515
attrs = ">=22.2.0"
16-
python-dateutil = "^2.8.0"
1716

1817
[build-system]
1918
requires = ["poetry-core>=2.0.0,<3.0.0"]

0 commit comments

Comments
 (0)