Skip to content

Commit c8b8194

Browse files
CopilotmsyycCopilot
authored
Support datetime.timedelta for seconds/milliseconds duration encoding (python) (#10947)
# Overview: Other language emitters have fully support this feature and this feature belongs to `core` features in spec dashboard. ## PR description Adds support in `@typespec/http-client-python` for SDK users to pass `datetime.timedelta` for durations encoded as `seconds`/`milliseconds` (in addition to the existing `ISO8601` support), and extends the `encode/duration` mock_api test coverage to exercise this. ## Changes ### Feature: timedelta support for seconds/milliseconds durations - **Emitter** (`emitter/src/types.ts`): `emitBuiltInType` now emits `seconds`/`milliseconds` `DurationKnownEncoding` values as a `duration` type carrying the `encode` and numeric `wireType`, alongside the existing `ISO8601` branch. - **Generator** (`generator/pygen/codegen/models/primitive_types.py`): `DurationType` reads `encode` + `wireType` and produces a combined format token `duration-{seconds|milliseconds}-{int|float}`; `ISO8601` retains the legacy `duration` behavior. - **Runtime serialization/deserialization** (both paths): - `model_base.py.jinja2` (DPG body model properties): added `_serialize_duration` plus seconds/milliseconds deserializers and the corresponding `_DESERIALIZE_MAPPING_WITHFORMAT` entries. - `serialization.py.jinja2` (msrest query/header params): added serialize/deserialize methods for the seconds/milliseconds (int/float) formats, registered in the type maps. This lets users pass `datetime.timedelta` and receive `datetime.timedelta` back for these encodings, with conversion to/from the numeric wire value handled by the runtime. ### Test coverage - **Added scenario coverage** across all three operation groups (`query`, `property`, `header`), now using `datetime.timedelta` input: - `milliseconds` encodings (`int32`, `float`, `float64`) - `larger-unit` encodings (`int32`/`float` seconds and milliseconds) - `milliseconds` arrays - **Files** (sync + async, both flavors): - `tests/mock_api/azure/test_encode_duration.py`, `tests/mock_api/azure/asynctests/test_encode_duration_async.py` - `tests/mock_api/unbranded/test_encode_duration.py`, `tests/mock_api/unbranded/asynctests/test_encode_duration_async.py` - **Property assertions** use the correct per-scenario models (e.g. `Float64SecondsDurationProperty`, `Int32MillisecondsLargerUnitDurationProperty`) and assert `datetime.timedelta` on responses. - **Changelog** entry under `.chronus/changes` (`changeKind: feature`). Example of newly-covered cases using `datetime.timedelta`: ```python client.query.int32_milliseconds_array( input=[datetime.timedelta(milliseconds=36000), datetime.timedelta(milliseconds=47000)] ) client.header.float_milliseconds_larger_unit(duration=datetime.timedelta(milliseconds=210000)) result = client.property.int32_milliseconds_larger_unit( Int32MillisecondsLargerUnitDurationProperty(value=datetime.timedelta(milliseconds=180000)) ) assert result.value == datetime.timedelta(milliseconds=180000) ``` ## Testing - ✅ Regenerated `encode/duration` for both azure and unbranded flavors; verified generated models use `datetime.timedelta` with the new format tokens - ✅ mock_api suites pass against the live mock server (azure + unbranded, sync + async — 12 passing) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: msyyc <70930885+msyyc@users.noreply.github.com> Co-authored-by: Yuchao Yan <yuchaoyan@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8494a66 commit c8b8194

9 files changed

Lines changed: 504 additions & 74 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Support `datetime.timedelta` for `duration` types encoded as `seconds` or `milliseconds`. SDK users can now pass a `datetime.timedelta` (instead of a raw `int`/`float`) and responses are deserialized back into `datetime.timedelta`.

packages/http-client-python/emitter/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,13 @@ function emitBuiltInType(
479479
encode: type.encode,
480480
});
481481
}
482+
if (type.encode === "seconds" || type.encode === "milliseconds") {
483+
return getSimpleTypeResult(context, {
484+
type: type.kind,
485+
encode: type.encode,
486+
wireType: getType(context, type.wireType),
487+
});
488+
}
482489
}
483490
if (type.kind === "utcDateTime" || type.kind === "offsetDateTime") {
484491
if (type.encode === "unixTimestamp") {

packages/http-client-python/generator/pygen/codegen/models/primitive_types.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,22 @@ def serialize_sample_value(value: Any) -> str:
522522

523523

524524
class DurationType(PrimitiveType):
525+
def __init__(self, yaml_data: dict[str, Any], code_model: "CodeModel") -> None:
526+
super().__init__(yaml_data=yaml_data, code_model=code_model)
527+
# ``seconds`` and ``milliseconds`` encodings serialize a timedelta to a numeric
528+
# wire value. ``encode`` is set to a combined format token (e.g.
529+
# ``duration-seconds-int``) so that serialization/deserialization can convert
530+
# between ``datetime.timedelta`` and the numeric wire type. ISO8601 (the default)
531+
# leaves ``encode`` unset and keeps the legacy ISO 8601 string behavior.
532+
self.encode: Optional[str] = None
533+
encode = yaml_data.get("encode")
534+
if encode in ("seconds", "milliseconds"):
535+
wire_type = yaml_data.get("wireType") or {}
536+
wire = "int" if wire_type.get("type") == "integer" else "float"
537+
self.encode = f"duration-{encode}-{wire}"
538+
525539
def serialization_type(self, **kwargs: Any) -> str:
526-
return "duration"
540+
return self.encode or "duration"
527541

528542
def docstring_type(self, **kwargs: Any) -> str:
529543
return "~" + self.type_annotation()

packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ def _serialize_bytes(o, format: typing.Optional[str] = None) -> str:
112112
return encoded
113113

114114

115+
def _serialize_duration(td: timedelta, format: typing.Optional[str] = None):
116+
"""Serialize a timedelta to its wire representation.
117+
118+
For the ``seconds``/``milliseconds`` encodings the value is converted to a
119+
numeric value, otherwise it falls back to an ISO 8601 duration string.
120+
121+
:param timedelta td: The timedelta to serialize.
122+
:param str format: The duration encoding format.
123+
:rtype: int or float or str
124+
:return: serialized duration
125+
"""
126+
seconds = td.total_seconds()
127+
if format == "duration-seconds-int":
128+
return int(seconds)
129+
if format == "duration-seconds-float":
130+
return seconds
131+
if format == "duration-milliseconds-int":
132+
return int(seconds * 1000)
133+
if format == "duration-milliseconds-float":
134+
return seconds * 1000
135+
return _timedelta_as_isostr(td)
136+
137+
115138
def _serialize_datetime(o, format: typing.Optional[str] = None):
116139
if hasattr(o, "year") and hasattr(o, "hour"):
117140
if format == "rfc7231":
@@ -307,6 +330,12 @@ def _deserialize_duration(attr):
307330
return isodate.parse_duration(attr)
308331

309332

333+
def _deserialize_duration_numeric(attr, unit):
334+
if isinstance(attr, timedelta):
335+
return attr
336+
return timedelta(**{unit: float(attr)})
337+
338+
310339
def _deserialize_decimal(attr):
311340
if isinstance(attr, decimal.Decimal):
312341
return attr
@@ -336,6 +365,10 @@ _DESERIALIZE_MAPPING_WITHFORMAT = {
336365
"unix-timestamp": _deserialize_datetime_unix_timestamp,
337366
"base64": _deserialize_bytes,
338367
"base64url": _deserialize_bytes_base64,
368+
"duration-seconds-int": functools.partial(_deserialize_duration_numeric, unit="seconds"),
369+
"duration-seconds-float": functools.partial(_deserialize_duration_numeric, unit="seconds"),
370+
"duration-milliseconds-int": functools.partial(_deserialize_duration_numeric, unit="milliseconds"),
371+
"duration-milliseconds-float": functools.partial(_deserialize_duration_numeric, unit="milliseconds"),
339372
}
340373

341374

@@ -576,7 +609,7 @@ def _serialize(o, format: typing.Optional[str] = None): # pylint: disable=too-m
576609
pass
577610
# Last, try datetime.timedelta
578611
try:
579-
return _timedelta_as_isostr(o)
612+
return _serialize_duration(o, format)
580613
except AttributeError:
581614
# This will be raised when it hits value.total_seconds in the method above
582615
pass

packages/http-client-python/generator/pygen/codegen/templates/serialization.py.jinja2

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,10 @@ class Serializer: # pylint: disable=too-many-public-methods
516516
"rfc-1123": Serializer.serialize_rfc,
517517
"unix-time": Serializer.serialize_unix,
518518
"duration": Serializer.serialize_duration,
519+
"duration-seconds-int": Serializer.serialize_duration_seconds_int,
520+
"duration-seconds-float": Serializer.serialize_duration_seconds_float,
521+
"duration-milliseconds-int": Serializer.serialize_duration_milliseconds_int,
522+
"duration-milliseconds-float": Serializer.serialize_duration_milliseconds_float,
519523
"date": Serializer.serialize_date,
520524
"time": Serializer.serialize_time,
521525
"decimal": Serializer.serialize_decimal,
@@ -1105,6 +1109,61 @@ class Serializer: # pylint: disable=too-many-public-methods
11051109
attr = isodate.parse_duration(attr)
11061110
return isodate.duration_isoformat(attr)
11071111

1112+
@staticmethod
1113+
def _serialize_duration_numeric(attr, scale, as_int):
1114+
"""Serialize a TimeDelta into a numeric value scaled to the wire unit.
1115+
1116+
:param TimeDelta attr: Object to be serialized.
1117+
:param int scale: Multiplier applied to total seconds (1 for seconds, 1000 for milliseconds).
1118+
:param bool as_int: Whether to truncate the result to an int.
1119+
:rtype: int or float
1120+
:return: serialized duration
1121+
"""
1122+
if isinstance(attr, str):
1123+
attr = isodate.parse_duration(attr)
1124+
value = attr.total_seconds() * scale if isinstance(attr, datetime.timedelta) else attr
1125+
return int(value) if as_int else float(value)
1126+
1127+
@staticmethod
1128+
def serialize_duration_seconds_int(attr, **kwargs): # pylint: disable=unused-argument
1129+
"""Serialize TimeDelta object into an integer number of seconds.
1130+
1131+
:param TimeDelta attr: Object to be serialized.
1132+
:rtype: int
1133+
:return: serialized duration
1134+
"""
1135+
return Serializer._serialize_duration_numeric(attr, 1, True)
1136+
1137+
@staticmethod
1138+
def serialize_duration_seconds_float(attr, **kwargs): # pylint: disable=unused-argument
1139+
"""Serialize TimeDelta object into a floating point number of seconds.
1140+
1141+
:param TimeDelta attr: Object to be serialized.
1142+
:rtype: float
1143+
:return: serialized duration
1144+
"""
1145+
return Serializer._serialize_duration_numeric(attr, 1, False)
1146+
1147+
@staticmethod
1148+
def serialize_duration_milliseconds_int(attr, **kwargs): # pylint: disable=unused-argument
1149+
"""Serialize TimeDelta object into an integer number of milliseconds.
1150+
1151+
:param TimeDelta attr: Object to be serialized.
1152+
:rtype: int
1153+
:return: serialized duration
1154+
"""
1155+
return Serializer._serialize_duration_numeric(attr, 1000, True)
1156+
1157+
@staticmethod
1158+
def serialize_duration_milliseconds_float(attr, **kwargs): # pylint: disable=unused-argument
1159+
"""Serialize TimeDelta object into a floating point number of milliseconds.
1160+
1161+
:param TimeDelta attr: Object to be serialized.
1162+
:rtype: float
1163+
:return: serialized duration
1164+
"""
1165+
return Serializer._serialize_duration_numeric(attr, 1000, False)
1166+
11081167
@staticmethod
11091168
def serialize_rfc(attr, **kwargs): # pylint: disable=unused-argument
11101169
"""Serialize Datetime object into RFC-1123 formatted string.
@@ -1377,6 +1436,10 @@ class Deserializer:
13771436
"rfc-1123": Deserializer.deserialize_rfc,
13781437
"unix-time": Deserializer.deserialize_unix,
13791438
"duration": Deserializer.deserialize_duration,
1439+
"duration-seconds-int": Deserializer.deserialize_duration_seconds,
1440+
"duration-seconds-float": Deserializer.deserialize_duration_seconds,
1441+
"duration-milliseconds-int": Deserializer.deserialize_duration_milliseconds,
1442+
"duration-milliseconds-float": Deserializer.deserialize_duration_milliseconds,
13801443
"date": Deserializer.deserialize_date,
13811444
"time": Deserializer.deserialize_time,
13821445
"decimal": Deserializer.deserialize_decimal,
@@ -1389,6 +1452,10 @@ class Deserializer:
13891452
}
13901453
self.deserialize_expected_types = {
13911454
"duration": (isodate.Duration, datetime.timedelta),
1455+
"duration-seconds-int": (isodate.Duration, datetime.timedelta),
1456+
"duration-seconds-float": (isodate.Duration, datetime.timedelta),
1457+
"duration-milliseconds-int": (isodate.Duration, datetime.timedelta),
1458+
"duration-milliseconds-float": (isodate.Duration, datetime.timedelta),
13921459
"iso-8601": (datetime.datetime),
13931460
}
13941461
self.dependencies: dict[str, type] = dict(classes) if classes else {}
@@ -1950,6 +2017,48 @@ class Deserializer:
19502017
raise DeserializationError(msg) from err
19512018
return duration
19522019

2020+
@staticmethod
2021+
def _deserialize_duration_numeric(attr, unit):
2022+
"""Deserialize a numeric duration value into a TimeDelta object.
2023+
2024+
:param float attr: response value to be deserialized.
2025+
:param str unit: The wire unit, used as the ``timedelta`` keyword
2026+
(``"seconds"`` or ``"milliseconds"``).
2027+
:return: Deserialized duration
2028+
:rtype: TimeDelta
2029+
:raises DeserializationError: if value is invalid.
2030+
"""
2031+
if isinstance(attr, ET.Element):
2032+
attr = attr.text
2033+
try:
2034+
duration = datetime.timedelta(**{unit: float(attr)}) # type: ignore
2035+
except (ValueError, OverflowError, TypeError) as err:
2036+
msg = "Cannot deserialize duration object."
2037+
raise DeserializationError(msg) from err
2038+
return duration
2039+
2040+
@staticmethod
2041+
def deserialize_duration_seconds(attr):
2042+
"""Deserialize a numeric number of seconds into a TimeDelta object.
2043+
2044+
:param float attr: response value to be deserialized.
2045+
:return: Deserialized duration
2046+
:rtype: TimeDelta
2047+
:raises DeserializationError: if value is invalid.
2048+
"""
2049+
return Deserializer._deserialize_duration_numeric(attr, "seconds")
2050+
2051+
@staticmethod
2052+
def deserialize_duration_milliseconds(attr):
2053+
"""Deserialize a numeric number of milliseconds into a TimeDelta object.
2054+
2055+
:param float attr: response value to be deserialized.
2056+
:return: Deserialized duration
2057+
:rtype: TimeDelta
2058+
:raises DeserializationError: if value is invalid.
2059+
"""
2060+
return Deserializer._deserialize_duration_numeric(attr, "milliseconds")
2061+
19532062
@staticmethod
19542063
def deserialize_date(attr):
19552064
"""Deserialize ISO-8601 formatted string into Date object.

packages/http-client-python/tests/mock_api/azure/asynctests/test_encode_duration_async.py

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@
99
import pytest_asyncio
1010
from encode.duration.aio import DurationClient
1111
from encode.duration.models import (
12-
Int32SecondsDurationProperty,
12+
DefaultDurationProperty,
1313
ISO8601DurationProperty,
14+
Int32SecondsDurationProperty,
1415
FloatSecondsDurationProperty,
15-
DefaultDurationProperty,
16+
Float64SecondsDurationProperty,
17+
Int32MillisecondsDurationProperty,
18+
FloatMillisecondsDurationProperty,
19+
Float64MillisecondsDurationProperty,
1620
FloatSecondsDurationArrayProperty,
21+
FloatMillisecondsDurationArrayProperty,
22+
Int32SecondsLargerUnitDurationProperty,
23+
FloatSecondsLargerUnitDurationProperty,
24+
Int32MillisecondsLargerUnitDurationProperty,
25+
FloatMillisecondsLargerUnitDurationProperty,
1726
)
1827

1928

@@ -27,10 +36,20 @@ async def client():
2736
async def test_query(client: DurationClient):
2837
await client.query.default(input=datetime.timedelta(days=40))
2938
await client.query.iso8601(input=datetime.timedelta(days=40))
30-
await client.query.int32_seconds(input=36)
31-
await client.query.int32_seconds_array(input=[36, 47])
32-
await client.query.float_seconds(input=35.625)
33-
await client.query.float64_seconds(input=35.625)
39+
await client.query.int32_seconds(input=datetime.timedelta(seconds=36))
40+
await client.query.int32_seconds_larger_unit(input=datetime.timedelta(seconds=120))
41+
await client.query.int32_seconds_array(input=[datetime.timedelta(seconds=36), datetime.timedelta(seconds=47)])
42+
await client.query.float_seconds(input=datetime.timedelta(seconds=35.625))
43+
await client.query.float_seconds_larger_unit(input=datetime.timedelta(seconds=150))
44+
await client.query.float64_seconds(input=datetime.timedelta(seconds=35.625))
45+
await client.query.int32_milliseconds(input=datetime.timedelta(milliseconds=36000))
46+
await client.query.int32_milliseconds_larger_unit(input=datetime.timedelta(milliseconds=180000))
47+
await client.query.int32_milliseconds_array(
48+
input=[datetime.timedelta(milliseconds=36000), datetime.timedelta(milliseconds=47000)]
49+
)
50+
await client.query.float_milliseconds(input=datetime.timedelta(milliseconds=35625))
51+
await client.query.float_milliseconds_larger_unit(input=datetime.timedelta(milliseconds=210000))
52+
await client.query.float64_milliseconds(input=datetime.timedelta(milliseconds=35625))
3453

3554

3655
@pytest.mark.asyncio
@@ -43,22 +62,69 @@ async def test_property(client: DurationClient):
4362
assert result.value == datetime.timedelta(days=40)
4463
result = await client.property.iso8601(ISO8601DurationProperty(value="P40D"))
4564
assert result.value == datetime.timedelta(days=40)
46-
result = await client.property.int32_seconds(Int32SecondsDurationProperty(value=36))
47-
assert result.value == 36
48-
result = await client.property.float_seconds(FloatSecondsDurationProperty(value=35.625))
49-
assert abs(result.value - 35.625) < 0.0001
50-
result = await client.property.float64_seconds(FloatSecondsDurationProperty(value=35.625))
51-
assert abs(result.value - 35.625) < 0.0001
52-
result = await client.property.float_seconds_array(FloatSecondsDurationArrayProperty(value=[35.625, 46.75]))
53-
assert abs(result.value[0] - 35.625) < 0.0001
54-
assert abs(result.value[1] - 46.75) < 0.0001
65+
result = await client.property.int32_seconds(Int32SecondsDurationProperty(value=datetime.timedelta(seconds=36)))
66+
assert result.value == datetime.timedelta(seconds=36)
67+
result = await client.property.float_seconds(FloatSecondsDurationProperty(value=datetime.timedelta(seconds=35.625)))
68+
assert result.value == datetime.timedelta(seconds=35.625)
69+
result = await client.property.float64_seconds(
70+
Float64SecondsDurationProperty(value=datetime.timedelta(seconds=35.625))
71+
)
72+
assert result.value == datetime.timedelta(seconds=35.625)
73+
result = await client.property.int32_milliseconds(
74+
Int32MillisecondsDurationProperty(value=datetime.timedelta(milliseconds=36000))
75+
)
76+
assert result.value == datetime.timedelta(milliseconds=36000)
77+
result = await client.property.float_milliseconds(
78+
FloatMillisecondsDurationProperty(value=datetime.timedelta(milliseconds=35625))
79+
)
80+
assert result.value == datetime.timedelta(milliseconds=35625)
81+
result = await client.property.float64_milliseconds(
82+
Float64MillisecondsDurationProperty(value=datetime.timedelta(milliseconds=35625))
83+
)
84+
assert result.value == datetime.timedelta(milliseconds=35625)
85+
result = await client.property.float_seconds_array(
86+
FloatSecondsDurationArrayProperty(value=[datetime.timedelta(seconds=35.625), datetime.timedelta(seconds=46.75)])
87+
)
88+
assert result.value == [datetime.timedelta(seconds=35.625), datetime.timedelta(seconds=46.75)]
89+
result = await client.property.float_milliseconds_array(
90+
FloatMillisecondsDurationArrayProperty(
91+
value=[datetime.timedelta(milliseconds=35625), datetime.timedelta(milliseconds=46750)]
92+
)
93+
)
94+
assert result.value == [datetime.timedelta(milliseconds=35625), datetime.timedelta(milliseconds=46750)]
95+
result = await client.property.int32_seconds_larger_unit(
96+
Int32SecondsLargerUnitDurationProperty(value=datetime.timedelta(seconds=120))
97+
)
98+
assert result.value == datetime.timedelta(seconds=120)
99+
result = await client.property.float_seconds_larger_unit(
100+
FloatSecondsLargerUnitDurationProperty(value=datetime.timedelta(seconds=150))
101+
)
102+
assert result.value == datetime.timedelta(seconds=150)
103+
result = await client.property.int32_milliseconds_larger_unit(
104+
Int32MillisecondsLargerUnitDurationProperty(value=datetime.timedelta(milliseconds=180000))
105+
)
106+
assert result.value == datetime.timedelta(milliseconds=180000)
107+
result = await client.property.float_milliseconds_larger_unit(
108+
FloatMillisecondsLargerUnitDurationProperty(value=datetime.timedelta(milliseconds=210000))
109+
)
110+
assert result.value == datetime.timedelta(milliseconds=210000)
55111

56112

57113
@pytest.mark.asyncio
58114
async def test_header(client: DurationClient):
59115
await client.header.default(duration=datetime.timedelta(days=40))
60116
await client.header.iso8601(duration=datetime.timedelta(days=40))
61117
await client.header.iso8601_array(duration=[datetime.timedelta(days=40), datetime.timedelta(days=50)])
62-
await client.header.int32_seconds(duration=36)
63-
await client.header.float_seconds(duration=35.625)
64-
await client.header.float64_seconds(duration=35.625)
118+
await client.header.int32_seconds(duration=datetime.timedelta(seconds=36))
119+
await client.header.int32_seconds_larger_unit(duration=datetime.timedelta(seconds=120))
120+
await client.header.float_seconds(duration=datetime.timedelta(seconds=35.625))
121+
await client.header.float_seconds_larger_unit(duration=datetime.timedelta(seconds=150))
122+
await client.header.float64_seconds(duration=datetime.timedelta(seconds=35.625))
123+
await client.header.int32_milliseconds(duration=datetime.timedelta(milliseconds=36000))
124+
await client.header.int32_milliseconds_larger_unit(duration=datetime.timedelta(milliseconds=180000))
125+
await client.header.int32_milliseconds_array(
126+
duration=[datetime.timedelta(milliseconds=36000), datetime.timedelta(milliseconds=47000)]
127+
)
128+
await client.header.float_milliseconds(duration=datetime.timedelta(milliseconds=35625))
129+
await client.header.float_milliseconds_larger_unit(duration=datetime.timedelta(milliseconds=210000))
130+
await client.header.float64_milliseconds(duration=datetime.timedelta(milliseconds=35625))

0 commit comments

Comments
 (0)