Skip to content

Commit ba323cb

Browse files
extract param parsing
1 parent 5d03563 commit ba323cb

File tree

3 files changed

+76
-18
lines changed

3 files changed

+76
-18
lines changed

src/icloudpd/base.py

Lines changed: 7 additions & 17 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,12 @@ 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:
282-
raise ValueError(
283-
f"Timestamp {formatted} for --skip-created-before parameter did not parse from ISO format successfully: {e}"
284-
) from e
269+
result = parse_timestamp_or_timedelta(formatted)
270+
if isinstance(result, ValueError):
271+
raise ValueError(f"--skip-created-before parameter: {result}")
272+
if isinstance(result, datetime.datetime) and result.tzinfo is None:
273+
result = result.astimezone(get_localzone())
274+
return result
285275

286276

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

src/icloudpd/string_helpers.py

Lines changed: 19 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 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,18 @@ 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_timestamp_or_timedelta(
23+
formatted: str,
24+
) -> Union[datetime.datetime, datetime.timedelta, ValueError]:
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+
29+
# try timestamp
30+
try:
31+
dt = datetime.datetime.fromisoformat(formatted)
32+
return dt
33+
except Exception as e:
34+
return ValueError(f"{formatted} did not parse timedelta and ISO format successfully: {e}")

tests/test_string_helpers.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import datetime
2+
import random
3+
import string
14
from unittest import TestCase
25

3-
from icloudpd.string_helpers import truncate_middle
6+
from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle
47

58

69
class TruncateMiddleTestCase(TestCase):
@@ -17,3 +20,49 @@ def test_truncate_middle(self) -> None:
1720
assert truncate_middle("test_filename.jpg", 0) == ""
1821
with self.assertRaises(ValueError):
1922
truncate_middle("test_filename.jpg", -1)
23+
24+
25+
class ParseTimestampeOrTimeDeltaTestCase(TestCase):
26+
def test_totality(self) -> None:
27+
characters = string.ascii_letters + string.digits
28+
for _case in range(500):
29+
target_length = random.randint(0, 100)
30+
target_string = "".join(random.choice(characters) for i in range(target_length))
31+
_result = parse_timestamp_or_timedelta(target_string)
32+
# not throwing is okay
33+
34+
def test_naive(self) -> None:
35+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006") == datetime.datetime(
36+
2025, 1, 2, 3, 4, 5, 600
37+
)
38+
39+
def test_aware(self) -> None:
40+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006Z") == datetime.datetime(
41+
2025, 1, 2, 3, 4, 5, 600, datetime.timezone.utc
42+
)
43+
44+
def test_aware_8(self) -> None:
45+
assert parse_timestamp_or_timedelta("2025-01-02T03:04:05.0006+0800") == datetime.datetime(
46+
2025, 1, 2, 3, 4, 5, 600, datetime.timezone(datetime.timedelta(hours=8))
47+
)
48+
49+
def test_timestamp_invalid(self) -> None:
50+
assert isinstance(parse_timestamp_or_timedelta("2025-01"), ValueError)
51+
52+
def test_delta_totality(self) -> None:
53+
digits = string.digits
54+
characters = ["d", "D"]
55+
for _case in range(500):
56+
target_length = random.randint(1, 5)
57+
target_string = "".join(
58+
random.choice(digits) for i in range(target_length)
59+
) + random.choice(characters)
60+
result = parse_timestamp_or_timedelta(target_string)
61+
assert isinstance(result, datetime.timedelta)
62+
63+
def test_delta(self) -> None:
64+
assert parse_timestamp_or_timedelta("1d") == datetime.timedelta(days=1)
65+
assert parse_timestamp_or_timedelta("0d") == datetime.timedelta(days=0)
66+
67+
def test_delta_invalid(self) -> None:
68+
assert isinstance(parse_timestamp_or_timedelta("-1d"), ValueError)

0 commit comments

Comments
 (0)