Skip to content

Commit 8a66dc4

Browse files
svrooijbaywet
andauthored
feat: remove pendulum from dependencies to prepare for 3.13 support
* Remove pendulum dependency Fixes #448 Remove the `pendulum` dependency and replace it with equivalent BCL/abstractions methods. * **Add `parseTimeDeltaFromIsoFormat` function**: - Implement a static function `parseTimeDeltaFromIsoFormat` in `packages/abstractions/kiota_abstractions/utils.py` to parse ISO 8601 duration strings. - Add import for `re` module. * **Replace `pendulum` calls**: - Replace `pendulum.parse` calls with equivalent BCL/abstractions methods in `packages/serialization/form/kiota_serialization_form/form_parse_node.py`. - Replace `pendulum.parse` calls with equivalent BCL/abstractions methods in `packages/serialization/json/kiota_serialization_json/json_parse_node.py`. * **Remove `pendulum` dependency**: - Remove `pendulum` dependency from `packages/serialization/form/pyproject.toml`. - Remove `pendulum` dependency from `packages/serialization/json/pyproject.toml`. --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/microsoft/kiota-python/issues/448?shareId=XXXX-XXXX-XXXX-XXXX). * Feedback processed and removed python-dateutil * Some linting * parse_timedelta acts exacty the same as previous library * Fix issues caused by auto format * More pendulem removed * Reduce complexity and raise error instead of return none * ci: disables fail fast to get all feedback at once Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: minor formatting fixes Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: additional formatting issue Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: additional formatting issues Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: replace calls to parser by iso format Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: additional formatting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: multiple text failing tests Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: additional fixture data corrections Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: additional fixes for unit test setup Signed-off-by: Vincent Biret <vibiret@microsoft.com> * Support additional timedeltas * Make P mandatory * chore: fixes formatting * fromisoformat compatibility * chore: linting * Hopefully try 50 fixes it 💣 * More 🕑 compatibility * Fixing datetime parsing in test 🧪 --------- Signed-off-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com>
1 parent 8d4fbe1 commit 8a66dc4

File tree

16 files changed

+293
-88
lines changed

16 files changed

+293
-88
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
timeout-minutes: 40
1818
strategy:
1919
max-parallel: 10
20+
fail-fast: false
2021
matrix:
2122
python-version: ["3.9", "3.10", "3.11", "3.12"]
2223
library :
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from sys import version_info as sys_version_info
2+
import re
3+
from datetime import datetime, time, timedelta
4+
5+
_ISO8601_DURATION_PATTERN = re.compile(
6+
"^P" # Duration P indicator
7+
# Weeks
8+
"(?P<w>"
9+
r" (?P<weeks>\d+(?:[.,]\d+)?W)"
10+
")?"
11+
# Years, Months, Days
12+
"(?P<ymd>"
13+
r" (?P<years>\d+(?:[.,]\d+)?Y)?"
14+
r" (?P<months>\d+(?:[.,]\d+)?M)?"
15+
r" (?P<days>\d+(?:[.,]\d+)?D)?"
16+
")?"
17+
# Time
18+
"(?P<hms>"
19+
" (?P<timesep>T)" # Separator (T)
20+
r" (?P<hours>\d+(?:[.,]\d+)?H)?"
21+
r" (?P<minutes>\d+(?:[.,]\d+)?M)?"
22+
r" (?P<seconds>\d+(?:[.,]\d+)?S)?"
23+
")?"
24+
"$",
25+
re.VERBOSE,
26+
)
27+
28+
29+
def parse_timedelta_from_iso_format(text: str) -> timedelta:
30+
"""Parses a ISO8601 duration string into a timedelta object."""
31+
32+
m = _ISO8601_DURATION_PATTERN.match(text)
33+
if not m:
34+
raise ValueError(f"Invalid ISO8601 duration string: {text}")
35+
36+
weeks = float(m.group("weeks").replace(",", ".").replace("W", "")) if m.group("weeks") else 0
37+
years = float(m.group("years").replace(",", ".").replace("Y", "")) if m.group("years") else 0
38+
months = float(m.group("months").replace(",", ".").replace("M", "")) if m.group("months") else 0
39+
days = float(m.group("days").replace(",", ".").replace("D", "")) if m.group("days") else 0
40+
hours = float(m.group("hours").replace(",", ".").replace("H", "")) if m.group("hours") else 0
41+
minutes = float(m.group("minutes").replace(",", ".").replace("M", "")
42+
) if m.group("minutes") else 0
43+
seconds = float(m.group("seconds").replace(",", ".").replace("S", "")
44+
) if m.group("seconds") else 0
45+
_have_date = years or months or days
46+
_have_time = hours or minutes or seconds
47+
if weeks and (_have_date or _have_time):
48+
raise ValueError("Combining weeks with other date/time parts is not supported")
49+
50+
_total_days = (years * 365) + (months * 30) + days
51+
return timedelta(
52+
days=_total_days,
53+
hours=hours,
54+
minutes=minutes,
55+
seconds=seconds,
56+
weeks=weeks,
57+
)
58+
59+
60+
_TIMEDELTA_PATTERN = re.compile(r"^(?P<hours>\d+):(?P<minutes>\d+)(?::(?P<seconds>\d+))?$")
61+
62+
63+
def parse_timedelta_string(text: str) -> timedelta:
64+
"""Checks if a given string is a valid ISO8601 duration string. Or hh:mm:ss format."""
65+
try:
66+
return parse_timedelta_from_iso_format(text)
67+
except ValueError as exc:
68+
# The previous library also supported hh:mm:ss format
69+
m = _TIMEDELTA_PATTERN.match(text)
70+
if not m:
71+
raise ValueError(f"Invalid timedelta string: {text}") from exc
72+
73+
hours = int(m.group("hours"))
74+
minutes = int(m.group("minutes"))
75+
seconds = int(m.group("seconds") or 0)
76+
return timedelta(hours=hours, minutes=minutes, seconds=seconds)
77+
78+
79+
_TIME_REPLACEMENT_PATTERN = re.compile(r'(\d)([.,])(\d+)')
80+
81+
82+
def datetime_from_iso_format_compat(text: str) -> datetime:
83+
"""Parses a ISO8601 formatted string into a datetime object."""
84+
try:
85+
# Try regular first (faster for most cases)
86+
return datetime.fromisoformat(text)
87+
except ValueError as exc:
88+
# Python 3.10 and below only support fractions of seconds in either 3 or 6 digits
89+
# Python 3.11+ supports any number of digits
90+
91+
if sys_version_info[:3] < (3, 11):
92+
# The following code is a workaround for Python 3.10 and below
93+
fixed_time = re.sub(
94+
_TIME_REPLACEMENT_PATTERN,
95+
lambda x: x.group(1) + "." + x.group(3).ljust(6, '0')[:6], text
96+
).replace("Z", "+00:00")
97+
return datetime.fromisoformat(fixed_time)
98+
99+
raise exc
100+
101+
102+
def time_from_iso_format_compat(text: str) -> time:
103+
"""Parses a ISO8601 formatted string into a time object."""
104+
try:
105+
# Try regular first (faster for most cases)
106+
return time.fromisoformat(text)
107+
except ValueError as exc:
108+
# Python 3.10 and below only support fractions of seconds in either 3 or 6 digits
109+
# Python 3.11+ supports any number of digits
110+
111+
if sys_version_info[:3] < (3, 11):
112+
# The following code is a workaround for Python 3.10 and below
113+
fixed_time = re.sub(
114+
_TIME_REPLACEMENT_PATTERN,
115+
lambda x: x.group(1) + "." + x.group(3).ljust(6, '0')[:6], text
116+
).replace("Z", "+00:00")
117+
return time.fromisoformat(fixed_time)
118+
119+
raise exc
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import pytest
2+
3+
from kiota_abstractions.date_utils import (
4+
parse_timedelta_from_iso_format,
5+
parse_timedelta_string,
6+
time_from_iso_format_compat,
7+
datetime_from_iso_format_compat
8+
)
9+
10+
11+
@pytest.mark.parametrize("text", ["08:00:00", "08:00:00.0", "08:00:00.00","08:00:00.000",
12+
"08:00:00.0000","08:00:00.00000","08:00:00.000000", "08:00:00.0000000", "08:00:00,0000000",
13+
"08:00:00,0000000Z", "08:00:00.00Z", "08:00:00.00+00:00" ])
14+
def test_time_from_iso_format_compat(text: str):
15+
result = time_from_iso_format_compat(text)
16+
assert result.hour == 8
17+
assert result.minute == 0
18+
assert result.second == 0
19+
20+
@pytest.mark.parametrize("text", ["1986-07-28T08:00:00", "1986-07-28T08:00:00.0", "1986-07-28T08:00:00.00",
21+
"1986-07-28T08:00:00.000", "1986-07-28T08:00:00.0000", "1986-07-28T08:00:00.00000",
22+
"1986-07-28T08:00:00.000000", "1986-07-28T08:00:00.0000000", "1986-07-28T08:00:00,0000000",
23+
"1986-07-28T08:00:00.0000000Z", "1986-07-28T08:00:00.00Z", "1986-07-28T08:00:00.00+00:00" ])
24+
def test_datetime_from_iso_format_compat(text: str):
25+
result = datetime_from_iso_format_compat(text)
26+
assert result.hour == 8
27+
assert result.minute == 0
28+
assert result.second == 0
29+
30+
31+
def test_parse_timedelta_from_iso_format_weeks():
32+
result = parse_timedelta_from_iso_format("P3W")
33+
assert result.days == 21
34+
35+
36+
def test_parse_timedelta_from_iso_format_days():
37+
result = parse_timedelta_from_iso_format("P3D")
38+
assert result.days == 3
39+
40+
41+
def test_parse_timedelta_from_iso_format_hours():
42+
result = parse_timedelta_from_iso_format("PT3H")
43+
assert result.seconds == 10800
44+
45+
46+
def test_parse_timedelta_from_iso_format_minutes():
47+
result = parse_timedelta_from_iso_format("PT3M")
48+
assert result.seconds == 180
49+
50+
51+
def test_parse_timedelta_from_iso_format_seconds():
52+
result = parse_timedelta_from_iso_format("PT3S")
53+
assert result.seconds == 3
54+
55+
56+
def test_parse_timedelta_from_iso_format_years():
57+
result = parse_timedelta_from_iso_format("P3Y")
58+
assert result.days == 1095
59+
60+
61+
def test_parse_timedelta_from_iso_format_months():
62+
result = parse_timedelta_from_iso_format("P3M")
63+
assert result.days == 90
64+
65+
66+
def test_parse_timedelta_from_iso_format_days_and_time():
67+
result = parse_timedelta_from_iso_format("P3DT3H3M3S")
68+
assert result.days == 3
69+
assert result.seconds == 10983
70+
71+
def test_parse_timedelta_from_iso_format_time_without_p():
72+
with pytest.raises(ValueError):
73+
parse_timedelta_from_iso_format("T3H3M3S")
74+
75+
@pytest.mark.parametrize("text", ["P3W3Y", "P3W3Y3D", "P3W3Y3DT3H3M3S"])
76+
def test_parse_timedelta_from_iso_format_must_raise(text: str):
77+
# assert this raises a ValueError
78+
with pytest.raises(ValueError):
79+
parse_timedelta_from_iso_format(text)
80+
81+
82+
@pytest.mark.parametrize("text, expected_hours", [("PT3H", 3), ("2:00:00", 2)])
83+
def test_parse_timedelta_string_valid(text:str, expected_hours:int):
84+
result = parse_timedelta_string(text)
85+
assert result.days == 0
86+
assert result.seconds == expected_hours * 3600

packages/serialization/form/kiota_serialization_form/form_parse_node.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from urllib.parse import unquote_plus
1010
from uuid import UUID
1111

12-
import pendulum
12+
from kiota_abstractions.date_utils import (
13+
parse_timedelta_string, datetime_from_iso_format_compat, time_from_iso_format_compat
14+
)
1315
from kiota_abstractions.serialization import Parsable, ParsableFactory, ParseNode
1416

1517
T = TypeVar("T", bool, str, int, float, UUID, datetime, timedelta, date, time, bytes)
@@ -93,10 +95,7 @@ def get_datetime_value(self) -> Optional[datetime]:
9395
"""
9496
if self._node and self._node != "null":
9597
try:
96-
datetime_obj = pendulum.parse(self._node, exact=True)
97-
if isinstance(datetime_obj, pendulum.DateTime):
98-
return datetime_obj
99-
return None
98+
return datetime_from_iso_format_compat(self._node)
10099
except:
101100
return None
102101
return None
@@ -108,10 +107,7 @@ def get_timedelta_value(self) -> Optional[timedelta]:
108107
"""
109108
if self._node and self._node != "null":
110109
try:
111-
datetime_obj = pendulum.parse(self._node, exact=True)
112-
if isinstance(datetime_obj, pendulum.Duration):
113-
return datetime_obj.as_timedelta()
114-
return None
110+
return parse_timedelta_string(self._node)
115111
except:
116112
return None
117113
return None
@@ -123,10 +119,7 @@ def get_date_value(self) -> Optional[date]:
123119
"""
124120
if self._node and self._node != "null":
125121
try:
126-
datetime_obj = pendulum.parse(self._node, exact=True)
127-
if isinstance(datetime_obj, pendulum.Date):
128-
return datetime_obj
129-
return None
122+
return date.fromisoformat(self._node)
130123
except:
131124
return None
132125
return None
@@ -138,10 +131,7 @@ def get_time_value(self) -> Optional[time]:
138131
"""
139132
if self._node and self._node != "null":
140133
try:
141-
datetime_obj = pendulum.parse(self._node, exact=True)
142-
if isinstance(datetime_obj, pendulum.Time):
143-
return datetime_obj
144-
return None
134+
return time_from_iso_format_compat(self._node)
145135
except:
146136
return None
147137
return None
@@ -315,16 +305,26 @@ def try_get_anything(self, value: Any) -> Any:
315305
return dict(map(lambda x: (x[0], self.try_get_anything(x[1])), value.items()))
316306
if isinstance(value, str):
317307
try:
318-
datetime_obj = pendulum.parse(value)
319-
if isinstance(datetime_obj, pendulum.Duration):
320-
return datetime_obj.as_timedelta()
308+
datetime_obj = datetime_from_iso_format_compat(value)
321309
return datetime_obj
322310
except ValueError:
323311
pass
324312
try:
325313
return UUID(value)
326314
except ValueError:
327315
pass
316+
try:
317+
return parse_timedelta_string(value)
318+
except ValueError:
319+
pass
320+
try:
321+
return date.fromisoformat(value)
322+
except ValueError:
323+
pass
324+
try:
325+
return time_from_iso_format_compat(value)
326+
except ValueError:
327+
pass
328328
return value
329329
raise ValueError(f"Unexpected additional value type {type(value)} during deserialization.")
330330

packages/serialization/form/pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ packages = [{include = "kiota_serialization_form"}]
2525
[tool.poetry.dependencies]
2626
python = ">=3.9,<4.0"
2727
microsoft-kiota-abstractions = {path="../../abstractions/", develop=true}
28-
pendulum = ">=3.0.0"
2928

3029
[tool.poetry.group.dev.dependencies]
3130
yapf = ">=0.40.2,<0.44.0"
@@ -52,4 +51,4 @@ profile = "hug"
5251

5352
[tool.poetry-monorepo.deps]
5453
enabled = true
55-
commands = ["build", "export", "publish"]
54+
commands = ["build", "export", "publish"]

packages/serialization/form/tests/unit/test_form_serialization_writer.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from urllib.parse import unquote_plus
33
import pytest
44

5-
import pendulum
65
from datetime import datetime, timedelta, date, time
76
from kiota_serialization_form.form_serialization_writer import FormSerializationWriter
87
from ..helpers import TestEntity, TestEnum
@@ -11,7 +10,7 @@
1110
@pytest.fixture
1211
def user_1():
1312
user = TestEntity()
14-
user.created_date_time = pendulum.parse("2022-01-27T12:59:45.596117")
13+
user.created_date_time = datetime.fromisoformat("2022-01-27T12:59:45.596117+00:00")
1514
user.work_duration = timedelta(seconds=7200)
1615
user.birthday = date(year=2000,month=9,day=4)
1716
user.start_work_time = time(hour=8, minute=0, second=0)
@@ -112,7 +111,7 @@ def test_write_time_value():
112111
form_serialization_writer = FormSerializationWriter()
113112
form_serialization_writer.write_time_value(
114113
"time",
115-
pendulum.parse('2022-01-27T12:59:45.596117').time()
114+
datetime.fromisoformat('2022-01-27T12:59:45.596117').time()
116115
)
117116
content = form_serialization_writer.get_serialized_content()
118117
content_string = content.decode('utf-8')

0 commit comments

Comments
 (0)