Skip to content

Commit 56040e4

Browse files
extract param parsing (#1147)
1 parent a8ecb88 commit 56040e4

File tree

3 files changed

+128
-17
lines changed

3 files changed

+128
-17
lines changed

src/icloudpd/base.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env python
22
"""Main script that uses Click to parse command-line arguments"""
33

4-
import re
54
from multiprocessing import freeze_support
65

76
import foundation
@@ -51,7 +50,7 @@
5150
from icloudpd.paths import clean_filename, local_download_path, remove_unicode_chars
5251
from icloudpd.server import serve_app
5352
from icloudpd.status import Status, StatusExchange
54-
from icloudpd.string_helpers import truncate_middle
53+
from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle
5554
from icloudpd.xmp_sidecar import generate_xmp_file
5655
from pyicloud_ipd.base import PyiCloudService
5756
from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
@@ -267,21 +266,20 @@ def skip_created_before_generator(
267266
) -> Optional[Union[datetime.datetime, datetime.timedelta]]:
268267
if formatted is None:
269268
return None
270-
# can be timestamp or timedelta
271-
m = re.match(r"(\d+)([dD]{1})", formatted)
272-
if m is not None and m.lastindex is not None and m.lastindex == 2:
273-
return datetime.timedelta(days=float(m.group(1)))
274-
275-
# try timestamp
276-
try:
277-
dt = datetime.datetime.fromisoformat(formatted)
278-
if dt.tzinfo is None:
279-
dt = dt.astimezone(get_localzone())
280-
return dt
281-
except Exception as e:
269+
result = parse_timestamp_or_timedelta(formatted)
270+
if result is None:
282271
raise ValueError(
283-
f"Timestamp {formatted} for --skip-created-before parameter did not parse from ISO format successfully: {e}"
284-
) from e
272+
"--skip-created-before parameter did not parse ISO timestamp or interval successfully"
273+
)
274+
if isinstance(result, datetime.datetime):
275+
return ensure_tzinfo(get_localzone(), result)
276+
return result
277+
278+
279+
def ensure_tzinfo(tz: datetime.tzinfo, input: datetime.datetime) -> datetime.datetime:
280+
if input.tzinfo is None:
281+
return input.astimezone(tz)
282+
return input
285283

286284

287285
def locale_setter(_ctx: click.Context, _param: click.Parameter, use_os_locale: bool) -> bool:

src/icloudpd/string_helpers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""String helper functions"""
22

3+
import datetime
4+
import re
5+
from typing import Optional, Union
6+
37

48
def truncate_middle(string: str, length: int) -> str:
59
"""Truncates a string to a maximum length, inserting "..." in the middle"""
@@ -13,3 +17,31 @@ def truncate_middle(string: str, length: int) -> str:
1317
start_length = length - end_length - 4
1418
end_length = max(end_length, 1)
1519
return f"{string[:start_length]}...{string[-end_length:]}"
20+
21+
22+
def parse_timedelta(
23+
formatted: str,
24+
) -> Optional[datetime.timedelta]:
25+
m = re.match(r"(\d+)([dD]{1})", formatted)
26+
if m is not None and m.lastindex is not None and m.lastindex == 2:
27+
return datetime.timedelta(days=float(m.group(1)))
28+
return None
29+
30+
31+
def parse_timestamp(
32+
formatted: str,
33+
) -> Optional[datetime.datetime]:
34+
try:
35+
dt = datetime.datetime.fromisoformat(formatted)
36+
return dt
37+
except Exception:
38+
return None
39+
40+
41+
def parse_timestamp_or_timedelta(
42+
formatted: str,
43+
) -> Optional[Union[datetime.datetime, datetime.timedelta]]:
44+
p1 = parse_timedelta(formatted)
45+
if p1 is None:
46+
return parse_timestamp(formatted)
47+
return p1

tests/test_string_helpers.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import datetime
2+
import random
3+
import string
4+
import sys
15
from unittest import TestCase
26

3-
from icloudpd.string_helpers import truncate_middle
7+
import pytest
8+
9+
from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle
410

511

612
class TruncateMiddleTestCase(TestCase):
@@ -17,3 +23,78 @@ def test_truncate_middle(self) -> None:
1723
assert truncate_middle("test_filename.jpg", 0) == ""
1824
with self.assertRaises(ValueError):
1925
truncate_middle("test_filename.jpg", -1)
26+
27+
28+
class ParseTimestampeOrTimeDeltaTestCase(TestCase):
29+
def test_totality(self) -> None:
30+
characters = string.ascii_letters + string.digits
31+
for _case in range(500):
32+
target_length = random.randint(0, 100)
33+
target_string = "".join(random.choice(characters) for i in range(target_length))
34+
_result = parse_timestamp_or_timedelta(target_string)
35+
# not throwing is okay
36+
37+
def test_naive(self) -> None:
38+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.000600") == datetime.datetime(
39+
2025, 1, 2, 3, 4, 5, 600
40+
)
41+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.006") == datetime.datetime(
42+
2025, 1, 2, 3, 4, 5, 6000
43+
)
44+
assert parse_timestamp_or_timedelta("2025-01-02") == datetime.datetime(
45+
2025, 1, 2, 0, 0, 0, 0
46+
)
47+
48+
@pytest.mark.skipif(
49+
sys.version_info < (3, 11), reason="Requires Python 3.11 or higher for full parsing support"
50+
)
51+
def test_naive_311plus(self) -> None:
52+
# short
53+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006") == datetime.datetime(
54+
2025, 1, 2, 3, 4, 5, 600
55+
)
56+
57+
def test_aware(self) -> None:
58+
assert parse_timestamp_or_timedelta(
59+
"2025-01-02T03:04:05.000600+08:00"
60+
) == datetime.datetime(
61+
2025, 1, 2, 3, 4, 5, 600, datetime.timezone(datetime.timedelta(hours=8))
62+
)
63+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.006+08:00") == datetime.datetime(
64+
2025, 1, 2, 3, 4, 5, 6000, datetime.timezone(datetime.timedelta(hours=8))
65+
)
66+
67+
@pytest.mark.skipif(
68+
sys.version_info < (3, 11), reason="Requires Python 3.11 or higher for full parsing support"
69+
)
70+
def test_aware_311plus(self) -> None:
71+
# short
72+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006Z") == datetime.datetime(
73+
2025, 1, 2, 3, 4, 5, 600, datetime.timezone.utc
74+
)
75+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006+08:00") == datetime.datetime(
76+
2025, 1, 2, 3, 4, 5, 600, datetime.timezone(datetime.timedelta(hours=8))
77+
)
78+
79+
def test_timestamp_invalid(self) -> None:
80+
assert parse_timestamp_or_timedelta("2025-01") is None
81+
assert parse_timestamp_or_timedelta("") is None
82+
83+
def test_delta_totality(self) -> None:
84+
digits = string.digits
85+
characters = ["d", "D"]
86+
for _case in range(500):
87+
target_length = random.randint(1, 5)
88+
target_string = "".join(
89+
random.choice(digits) for i in range(target_length)
90+
) + random.choice(characters)
91+
result = parse_timestamp_or_timedelta(target_string)
92+
assert isinstance(result, datetime.timedelta)
93+
94+
def test_delta(self) -> None:
95+
assert parse_timestamp_or_timedelta("1d") == datetime.timedelta(days=1)
96+
assert parse_timestamp_or_timedelta("0d") == datetime.timedelta(days=0)
97+
98+
def test_delta_invalid(self) -> None:
99+
assert parse_timestamp_or_timedelta("-1d") is None
100+
assert parse_timestamp_or_timedelta("") is None

0 commit comments

Comments
 (0)