icloud-cli-tools 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {icloud_cli_tools-0.1.2/src/icloud_cli_tools.egg-info → icloud_cli_tools-0.1.3}/PKG-INFO +3 -2
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/README.md +2 -1
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/pyproject.toml +1 -1
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/__init__.py +1 -1
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/auth.py +44 -20
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/main.py +7 -1
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/reminders.py +191 -38
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3/src/icloud_cli_tools.egg-info}/PKG-INFO +3 -2
- icloud_cli_tools-0.1.3/tests/test_auth.py +207 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_reminders.py +202 -0
- icloud_cli_tools-0.1.2/tests/test_auth.py +0 -97
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/.github/workflows/ci.yml +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/.gitignore +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/LICENSE +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/setup.cfg +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/config.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/daemon.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/output.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/__init__.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/calendar.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/findmy.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/notes.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/SOURCES.txt +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/dependency_links.txt +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/entry_points.txt +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/requires.txt +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/top_level.txt +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/systemd/icloud-cli-sync.service +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/conftest.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_calendar.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_findmy.py +0 -0
- {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_notes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: icloud-cli-tools
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Access iCloud Calendar, Reminders, Notes, and Find My from Linux
|
|
5
5
|
Author: alan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,7 +120,8 @@ icloud-cli calendar delete <event-id>
|
|
|
120
120
|
```bash
|
|
121
121
|
icloud-cli reminders list # All active reminders
|
|
122
122
|
icloud-cli reminders list --list "Shopping" --completed
|
|
123
|
-
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
|
|
123
|
+
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
|
|
124
|
+
icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
|
|
124
125
|
icloud-cli reminders complete <reminder-id>
|
|
125
126
|
icloud-cli reminders delete <reminder-id>
|
|
126
127
|
```
|
|
@@ -84,7 +84,8 @@ icloud-cli calendar delete <event-id>
|
|
|
84
84
|
```bash
|
|
85
85
|
icloud-cli reminders list # All active reminders
|
|
86
86
|
icloud-cli reminders list --list "Shopping" --completed
|
|
87
|
-
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
|
|
87
|
+
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
|
|
88
|
+
icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
|
|
88
89
|
icloud-cli reminders complete <reminder-id>
|
|
89
90
|
icloud-cli reminders delete <reminder-id>
|
|
90
91
|
```
|
|
@@ -87,46 +87,70 @@ class AuthManager:
|
|
|
87
87
|
return True
|
|
88
88
|
|
|
89
89
|
def _handle_2fa(self) -> bool:
|
|
90
|
-
"""Handle two-factor authentication (trusted device code)."""
|
|
90
|
+
"""Handle two-factor authentication (HSA2 trusted device or SMS code)."""
|
|
91
91
|
info("Two-factor authentication required.")
|
|
92
92
|
|
|
93
93
|
try:
|
|
94
|
-
self._api.request_2fa_code()
|
|
94
|
+
delivered = self._api.request_2fa_code()
|
|
95
95
|
except Exception as e:
|
|
96
96
|
error(f"Failed to trigger 2FA delivery: {e}")
|
|
97
97
|
return False
|
|
98
98
|
|
|
99
|
+
if not delivered:
|
|
100
|
+
# Don't prompt for a code that never arrived. pyicloud identifies
|
|
101
|
+
# hardware-key challenges explicitly; a false return with any
|
|
102
|
+
# other method means Apple offered no supported delivery channel.
|
|
103
|
+
if self._api.two_factor_delivery_method == "security_key":
|
|
104
|
+
error(
|
|
105
|
+
"This 2FA challenge requires a hardware security key, "
|
|
106
|
+
"which icloud-cli does not support."
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
error(
|
|
110
|
+
"Apple did not offer a supported 2FA code-delivery channel. "
|
|
111
|
+
"Check that your account has a trusted device or phone number."
|
|
112
|
+
)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
# Describe the channel we'll actually validate against. validate_2fa_code()
|
|
116
|
+
# keys off two_factor_delivery_method, so the prompt must match it — telling
|
|
117
|
+
# the user to enter the SMS code when we're verifying the trusted-device
|
|
118
|
+
# code (or vice versa) is what made the old prompt misleading.
|
|
99
119
|
method = self._api.two_factor_delivery_method
|
|
100
120
|
notice = self._api.two_factor_delivery_notice
|
|
101
121
|
|
|
102
|
-
if method == "
|
|
103
|
-
info("
|
|
104
|
-
info("Enter the
|
|
105
|
-
elif method == "sms":
|
|
106
|
-
info("A verification code has been sent via SMS.")
|
|
122
|
+
if method == "sms":
|
|
123
|
+
info("Apple sent a 6-digit verification code by SMS.")
|
|
124
|
+
info("Enter the code from the text message.")
|
|
107
125
|
else:
|
|
108
|
-
info("
|
|
126
|
+
info("Apple sent a 6-digit verification code to your trusted devices.")
|
|
127
|
+
info("Enter the code shown in the trusted-device notification.")
|
|
109
128
|
|
|
110
129
|
if notice:
|
|
111
130
|
warning(notice)
|
|
112
131
|
|
|
113
|
-
|
|
132
|
+
max_attempts = 3
|
|
133
|
+
for attempt in range(max_attempts):
|
|
134
|
+
code = click.prompt("Enter 2FA code")
|
|
135
|
+
try:
|
|
136
|
+
if self._api.validate_2fa_code(code):
|
|
137
|
+
break
|
|
138
|
+
except Exception as e:
|
|
139
|
+
error(f"2FA verification failed: {e}")
|
|
140
|
+
return False
|
|
114
141
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if not result:
|
|
142
|
+
remaining = max_attempts - attempt - 1
|
|
143
|
+
if remaining <= 0:
|
|
118
144
|
error("Invalid 2FA code.")
|
|
119
145
|
return False
|
|
146
|
+
warning(f"Invalid 2FA code. {remaining} attempt(s) remaining.")
|
|
120
147
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
if not self._api.is_trusted_session:
|
|
149
|
+
info("Trusting this session...")
|
|
150
|
+
self._api.trust_session()
|
|
124
151
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
except Exception as e:
|
|
128
|
-
error(f"2FA verification failed: {e}")
|
|
129
|
-
return False
|
|
152
|
+
success("2FA verification successful!")
|
|
153
|
+
return True
|
|
130
154
|
|
|
131
155
|
def _handle_2sa(self) -> bool:
|
|
132
156
|
"""Handle two-step authentication (SMS/phone-based)."""
|
|
@@ -206,7 +206,13 @@ def reminders_list(ctx: AppContext, list_name: str | None, completed: bool):
|
|
|
206
206
|
|
|
207
207
|
@reminders.command("add")
|
|
208
208
|
@click.option("--title", "-t", required=True, help="Reminder title.")
|
|
209
|
-
@click.option(
|
|
209
|
+
@click.option(
|
|
210
|
+
"--due",
|
|
211
|
+
"-d",
|
|
212
|
+
default=None,
|
|
213
|
+
help="Due date. A bare date (YYYY-MM-DD) creates an all-day reminder; "
|
|
214
|
+
"add a time (YYYY-MM-DD HH:MM) for a timed reminder.",
|
|
215
|
+
)
|
|
210
216
|
@click.option("--list", "-l", "list_name", default=None, help="Reminder list name.")
|
|
211
217
|
@click.option("--description", default=None, help="Reminder description.")
|
|
212
218
|
@pass_context
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"""Reminders service for icloud-cli.
|
|
2
2
|
|
|
3
3
|
Provides CRUD operations for iCloud Reminders via pyicloud.
|
|
4
|
+
|
|
5
|
+
Supports cursor-based incremental sync: the first call does a full CloudKit sync.
|
|
6
|
+
Subsequent calls use iter_changes(since=cursor) to fetch only changes.
|
|
7
|
+
State is persisted under config.cache_dir/reminders/.
|
|
4
8
|
"""
|
|
5
9
|
|
|
6
10
|
from __future__ import annotations
|
|
@@ -8,7 +12,9 @@ from __future__ import annotations
|
|
|
8
12
|
import json
|
|
9
13
|
import time
|
|
10
14
|
import uuid
|
|
15
|
+
from contextlib import suppress
|
|
11
16
|
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
12
18
|
from typing import Any
|
|
13
19
|
|
|
14
20
|
from dateutil import parser as dateutil_parser
|
|
@@ -25,12 +31,20 @@ class RemindersService:
|
|
|
25
31
|
self.config = config
|
|
26
32
|
self._reminders_service = api.reminders
|
|
27
33
|
|
|
34
|
+
# Cursor-based incremental sync state
|
|
35
|
+
self._cache_dir = Path(config.cache_dir) / "reminders"
|
|
36
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self._state_file = self._cache_dir / "state.json"
|
|
38
|
+
|
|
28
39
|
def list_reminders(
|
|
29
40
|
self,
|
|
30
41
|
list_name: str | None = None,
|
|
31
42
|
show_completed: bool = False,
|
|
32
43
|
) -> list[dict[str, Any]]:
|
|
33
|
-
"""List reminders
|
|
44
|
+
"""List reminders with cursor-based incremental sync.
|
|
45
|
+
|
|
46
|
+
First call triggers a full CloudKit sync (~5 min for 200 reminders).
|
|
47
|
+
Subsequent calls fetch only changes since the last cursor (~1s).
|
|
34
48
|
|
|
35
49
|
Args:
|
|
36
50
|
list_name: Filter to specific reminder list.
|
|
@@ -39,45 +53,122 @@ class RemindersService:
|
|
|
39
53
|
Returns:
|
|
40
54
|
List of reminder dictionaries.
|
|
41
55
|
"""
|
|
56
|
+
|
|
57
|
+
state = self._load_sync_state()
|
|
58
|
+
|
|
42
59
|
try:
|
|
43
|
-
|
|
60
|
+
rlists = list(self._reminders_service.lists())
|
|
61
|
+
list_map = {lst.id: lst.title for lst in rlists}
|
|
62
|
+
|
|
63
|
+
# Capture an upper bound before reading any reminder records. If a
|
|
64
|
+
# change arrives while the read is in progress, keeping this older
|
|
65
|
+
# cursor makes the next invocation replay that change safely.
|
|
66
|
+
next_cursor = self._reminders_service.sync_cursor()
|
|
67
|
+
|
|
68
|
+
if state is None:
|
|
69
|
+
data_map = self._full_reminder_snapshot(rlists, list_map)
|
|
70
|
+
else:
|
|
71
|
+
cursor, cached_data = state
|
|
72
|
+
data_map = {entry["id"]: entry for entry in cached_data}
|
|
73
|
+
|
|
74
|
+
# List changes are not emitted by iter_changes(), so refresh
|
|
75
|
+
# cached display names from the lightweight list snapshot.
|
|
76
|
+
for entry in data_map.values():
|
|
77
|
+
entry_list_id = entry.get("list_id")
|
|
78
|
+
if entry_list_id:
|
|
79
|
+
entry["list"] = list_map.get(entry_list_id, entry_list_id)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
for change in self._reminders_service.iter_changes(since=cursor):
|
|
83
|
+
reminder_id = change.reminder_id
|
|
84
|
+
reminder = change.reminder
|
|
85
|
+
if change.type == "deleted" or not reminder or reminder.deleted:
|
|
86
|
+
data_map.pop(reminder_id, None)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
list_title = list_map.get(reminder.list_id, reminder.list_id)
|
|
90
|
+
data_map[reminder_id] = self._reminder_to_entry(
|
|
91
|
+
reminder, list_title
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
# An expired or account-mismatched cursor requires a fresh
|
|
95
|
+
# snapshot. next_cursor was captured before this read, so
|
|
96
|
+
# later changes will still be replayed on the next call.
|
|
97
|
+
data_map = self._full_reminder_snapshot(rlists, list_map)
|
|
44
98
|
except Exception as e:
|
|
45
99
|
from icloud_cli.output import error
|
|
46
100
|
error(f"Failed to fetch reminders: {e}")
|
|
47
101
|
return []
|
|
48
102
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
cached_data = list(data_map.values())
|
|
104
|
+
# The old state remains intact on failure because replacement is
|
|
105
|
+
# atomic, so the next invocation replays from its older cursor.
|
|
106
|
+
with suppress(OSError):
|
|
107
|
+
self._write_sync_state(next_cursor, cached_data)
|
|
53
108
|
|
|
54
|
-
|
|
109
|
+
result = []
|
|
110
|
+
for cached_entry in cached_data:
|
|
111
|
+
entry = {key: value for key, value in cached_entry.items() if key != "list_id"}
|
|
112
|
+
if list_name and entry["list"].lower() != list_name.lower():
|
|
55
113
|
continue
|
|
114
|
+
if not show_completed and entry["completed"]:
|
|
115
|
+
continue
|
|
116
|
+
result.append(entry)
|
|
117
|
+
return result
|
|
56
118
|
|
|
57
|
-
|
|
119
|
+
def _load_sync_state(self) -> tuple[str, list[dict[str, Any]]] | None:
|
|
120
|
+
"""Load a complete cursor/data generation, or request a full sync."""
|
|
121
|
+
if not self._state_file.exists():
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
state = json.loads(self._state_file.read_text())
|
|
125
|
+
if state.get("version") != 1 or state.get("account") != self.config.apple_id:
|
|
126
|
+
return None
|
|
127
|
+
cursor = state["cursor"]
|
|
128
|
+
entries = state["entries"]
|
|
129
|
+
entries_are_valid = isinstance(entries, list) and all(
|
|
130
|
+
isinstance(entry, dict)
|
|
131
|
+
and isinstance(entry.get("id"), str)
|
|
132
|
+
and isinstance(entry.get("list_id"), str)
|
|
133
|
+
for entry in entries
|
|
134
|
+
)
|
|
135
|
+
if not isinstance(cursor, str) or not entries_are_valid:
|
|
136
|
+
return None
|
|
137
|
+
return cursor, entries
|
|
138
|
+
except (json.JSONDecodeError, KeyError, OSError, TypeError, AttributeError):
|
|
139
|
+
return None
|
|
58
140
|
|
|
59
|
-
|
|
60
|
-
|
|
141
|
+
def _write_sync_state(self, cursor: str, entries: list[dict[str, Any]]) -> None:
|
|
142
|
+
"""Atomically persist cursor and entries as one cache generation."""
|
|
143
|
+
state = {
|
|
144
|
+
"version": 1,
|
|
145
|
+
"account": self.config.apple_id,
|
|
146
|
+
"cursor": cursor,
|
|
147
|
+
"entries": entries,
|
|
148
|
+
}
|
|
149
|
+
temp_file = self._state_file.with_name(
|
|
150
|
+
f".{self._state_file.name}.{uuid.uuid4().hex}.tmp"
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
temp_file.write_text(json.dumps(state, indent=2))
|
|
154
|
+
temp_file.replace(self._state_file)
|
|
155
|
+
finally:
|
|
156
|
+
if temp_file.exists():
|
|
157
|
+
temp_file.unlink()
|
|
158
|
+
|
|
159
|
+
def _full_reminder_snapshot(
|
|
160
|
+
self, rlists: list[Any], list_map: dict[str, str]
|
|
161
|
+
) -> dict[str, dict[str, Any]]:
|
|
162
|
+
"""Fetch a complete deduplicated reminder snapshot."""
|
|
163
|
+
data_map: dict[str, dict[str, Any]] = {}
|
|
164
|
+
for rlist in rlists:
|
|
165
|
+
for reminder in self._reminders_service.reminders(list_id=rlist.id):
|
|
166
|
+
if getattr(reminder, "deleted", False):
|
|
61
167
|
continue
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
priority_map = {0: "", 1: "High", 5: "Medium", 9: "Low"}
|
|
68
|
-
priority = priority_map.get(reminder.priority, "")
|
|
69
|
-
|
|
70
|
-
result.append({
|
|
71
|
-
"id": reminder.id,
|
|
72
|
-
"title": reminder.title,
|
|
73
|
-
"list": rlist_title,
|
|
74
|
-
"due_date": due_date,
|
|
75
|
-
"priority": priority,
|
|
76
|
-
"completed": "✓" if reminder.completed else "",
|
|
77
|
-
"description": reminder.desc,
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
return result
|
|
168
|
+
data_map[reminder.id] = self._reminder_to_entry(
|
|
169
|
+
reminder, list_map.get(reminder.list_id, reminder.list_id)
|
|
170
|
+
)
|
|
171
|
+
return data_map
|
|
81
172
|
|
|
82
173
|
def list_reminder_lists(self) -> list[dict[str, Any]]:
|
|
83
174
|
"""List reminder lists."""
|
|
@@ -249,12 +340,15 @@ class RemindersService:
|
|
|
249
340
|
error("No reminder lists found.")
|
|
250
341
|
return False
|
|
251
342
|
|
|
252
|
-
# Parse due date
|
|
343
|
+
# Parse due date. When the user gives a bare date with no time
|
|
344
|
+
# (e.g. "2025-06-15"), create an all-day reminder so it shows the
|
|
345
|
+
# date without a "12:00 AM" time, matching the Reminders app.
|
|
253
346
|
due: datetime | None = None
|
|
347
|
+
all_day = False
|
|
254
348
|
if due_date:
|
|
255
349
|
try:
|
|
256
|
-
due =
|
|
257
|
-
except (ValueError, TypeError):
|
|
350
|
+
due, all_day = _parse_due_date(due_date)
|
|
351
|
+
except (ValueError, TypeError, OverflowError):
|
|
258
352
|
from icloud_cli.output import error
|
|
259
353
|
error("Invalid date format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM.")
|
|
260
354
|
return False
|
|
@@ -264,6 +358,7 @@ class RemindersService:
|
|
|
264
358
|
title=title,
|
|
265
359
|
desc=description or "",
|
|
266
360
|
due_date=due,
|
|
361
|
+
all_day=all_day,
|
|
267
362
|
priority=0,
|
|
268
363
|
)
|
|
269
364
|
return True
|
|
@@ -273,6 +368,27 @@ class RemindersService:
|
|
|
273
368
|
error(f"Failed to create reminder: {e}")
|
|
274
369
|
return False
|
|
275
370
|
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _reminder_to_entry(r, list_title: str) -> dict[str, Any]:
|
|
373
|
+
"""Convert a pyicloud Reminder model to the icloud-cli dict format."""
|
|
374
|
+
priority_map = {0: "", 1: "High", 5: "Medium", 9: "Low"}
|
|
375
|
+
due_date = ""
|
|
376
|
+
if r.due_date:
|
|
377
|
+
due_date = _format_due_date(
|
|
378
|
+
r.due_date,
|
|
379
|
+
date_only=bool(getattr(r, "all_day", False)),
|
|
380
|
+
)
|
|
381
|
+
return {
|
|
382
|
+
"id": r.id,
|
|
383
|
+
"list_id": r.list_id,
|
|
384
|
+
"title": r.title,
|
|
385
|
+
"list": list_title,
|
|
386
|
+
"due_date": due_date,
|
|
387
|
+
"priority": priority_map.get(r.priority, ""),
|
|
388
|
+
"completed": "✓" if r.completed else "",
|
|
389
|
+
"description": r.desc,
|
|
390
|
+
}
|
|
391
|
+
|
|
276
392
|
def _find_list_id(self, list_name: str) -> str | None:
|
|
277
393
|
for rlist in self._reminders_service.lists():
|
|
278
394
|
if rlist.title.lower() == list_name.lower() or rlist.id == list_name:
|
|
@@ -350,29 +466,66 @@ class RemindersService:
|
|
|
350
466
|
return False
|
|
351
467
|
|
|
352
468
|
|
|
353
|
-
def
|
|
354
|
-
"""
|
|
469
|
+
def _parse_due_date(due_date: str) -> tuple[datetime, bool]:
|
|
470
|
+
"""Parse a due-date string, detecting whether a time was supplied.
|
|
471
|
+
|
|
472
|
+
Returns a ``(datetime, all_day)`` tuple. When the input carries no time
|
|
473
|
+
component the reminder is treated as all-day and anchored to noon so the
|
|
474
|
+
stored calendar date stays stable across time zones (pyicloud persists a
|
|
475
|
+
naive datetime as UTC).
|
|
476
|
+
|
|
477
|
+
Date fields the user omits fall back to today, matching ``dateutil``'s
|
|
478
|
+
default. The two parse defaults share the same date and differ only in
|
|
479
|
+
their time fields, so a time the user actually typed resolves identically
|
|
480
|
+
against both while a time left to the default does not -- that is what
|
|
481
|
+
distinguishes a bare date from a timed one.
|
|
482
|
+
"""
|
|
483
|
+
today = datetime.now()
|
|
484
|
+
default_a = today.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
485
|
+
default_b = today.replace(hour=23, minute=59, second=58, microsecond=0)
|
|
486
|
+
parsed_a = dateutil_parser.parse(due_date, default=default_a)
|
|
487
|
+
parsed_b = dateutil_parser.parse(due_date, default=default_b)
|
|
488
|
+
has_time = (
|
|
489
|
+
parsed_a.hour == parsed_b.hour
|
|
490
|
+
or parsed_a.minute == parsed_b.minute
|
|
491
|
+
or parsed_a.second == parsed_b.second
|
|
492
|
+
)
|
|
493
|
+
if has_time:
|
|
494
|
+
return parsed_a, False
|
|
495
|
+
anchored = parsed_a.replace(hour=12, minute=0, second=0, microsecond=0)
|
|
496
|
+
return anchored, True
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _format_due_date(due_date: Any, date_only: bool = False) -> str:
|
|
500
|
+
"""Format a due date from the API response.
|
|
501
|
+
|
|
502
|
+
When ``date_only`` is True (all-day reminders), the time component is
|
|
503
|
+
omitted so the reminder shows just its date.
|
|
504
|
+
"""
|
|
505
|
+
fmt = "%Y-%m-%d" if date_only else "%Y-%m-%d %H:%M"
|
|
355
506
|
if isinstance(due_date, str):
|
|
356
507
|
try:
|
|
357
508
|
dt = dateutil_parser.parse(due_date)
|
|
358
|
-
return dt.strftime(
|
|
509
|
+
return dt.strftime(fmt)
|
|
359
510
|
except (ValueError, TypeError):
|
|
360
511
|
return due_date
|
|
361
|
-
if isinstance(due_date, list) and len(due_date) >=
|
|
512
|
+
if isinstance(due_date, list) and len(due_date) >= 5:
|
|
362
513
|
# iCloud format: [year, month, day, hour, minute]
|
|
363
514
|
try:
|
|
364
515
|
date_part = f"{due_date[0]:04d}-{due_date[1]:02d}-{due_date[2]:02d}"
|
|
516
|
+
if date_only:
|
|
517
|
+
return date_part
|
|
365
518
|
time_part = f"{due_date[3]:02d}:{due_date[4]:02d}"
|
|
366
519
|
return f"{date_part} {time_part}"
|
|
367
520
|
except (IndexError, TypeError):
|
|
368
521
|
return str(due_date)
|
|
369
522
|
if isinstance(due_date, (int, float)):
|
|
370
523
|
try:
|
|
371
|
-
return datetime.fromtimestamp(due_date / 1000).strftime(
|
|
524
|
+
return datetime.fromtimestamp(due_date / 1000).strftime(fmt)
|
|
372
525
|
except (ValueError, OSError):
|
|
373
526
|
return str(due_date)
|
|
374
527
|
if isinstance(due_date, datetime):
|
|
375
|
-
return due_date.strftime(
|
|
528
|
+
return due_date.strftime(fmt)
|
|
376
529
|
return str(due_date)
|
|
377
530
|
|
|
378
531
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: icloud-cli-tools
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Access iCloud Calendar, Reminders, Notes, and Find My from Linux
|
|
5
5
|
Author: alan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,7 +120,8 @@ icloud-cli calendar delete <event-id>
|
|
|
120
120
|
```bash
|
|
121
121
|
icloud-cli reminders list # All active reminders
|
|
122
122
|
icloud-cli reminders list --list "Shopping" --completed
|
|
123
|
-
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
|
|
123
|
+
icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
|
|
124
|
+
icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
|
|
124
125
|
icloud-cli reminders complete <reminder-id>
|
|
125
126
|
icloud-cli reminders delete <reminder-id>
|
|
126
127
|
```
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Tests for the auth module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from icloud_cli.auth import AuthManager
|
|
8
|
+
from icloud_cli.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestAuthManager:
|
|
12
|
+
"""Tests for AuthManager."""
|
|
13
|
+
|
|
14
|
+
def test_get_status_not_logged_in(self, tmp_path):
|
|
15
|
+
"""Status shows not logged in when no credentials stored."""
|
|
16
|
+
config = Config(
|
|
17
|
+
apple_id="",
|
|
18
|
+
session_dir=str(tmp_path / "session"),
|
|
19
|
+
config_file=tmp_path / "config.toml",
|
|
20
|
+
)
|
|
21
|
+
auth = AuthManager(config)
|
|
22
|
+
status = auth.get_status()
|
|
23
|
+
|
|
24
|
+
assert status["apple_id"] == "(not set)"
|
|
25
|
+
assert status["password_stored"] == "No"
|
|
26
|
+
assert status["session_cached"] == "No"
|
|
27
|
+
|
|
28
|
+
@patch("icloud_cli.auth.keyring")
|
|
29
|
+
def test_get_status_logged_in(self, mock_keyring, tmp_path):
|
|
30
|
+
"""Status shows logged in when credentials are stored."""
|
|
31
|
+
config = Config(
|
|
32
|
+
apple_id="test@icloud.com",
|
|
33
|
+
session_dir=str(tmp_path / "session"),
|
|
34
|
+
config_file=tmp_path / "config.toml",
|
|
35
|
+
)
|
|
36
|
+
# Create a session file
|
|
37
|
+
session_dir = tmp_path / "session"
|
|
38
|
+
session_dir.mkdir(parents=True)
|
|
39
|
+
(session_dir / "session_cookie").write_text("fake_cookie")
|
|
40
|
+
|
|
41
|
+
mock_keyring.get_password.return_value = "stored_password"
|
|
42
|
+
|
|
43
|
+
auth = AuthManager(config)
|
|
44
|
+
status = auth.get_status()
|
|
45
|
+
|
|
46
|
+
assert status["apple_id"] == "test@icloud.com"
|
|
47
|
+
assert status["password_stored"] == "Yes"
|
|
48
|
+
assert status["session_cached"] == "Yes"
|
|
49
|
+
|
|
50
|
+
@patch("icloud_cli.auth.keyring")
|
|
51
|
+
def test_logout_clears_session(self, mock_keyring, tmp_path):
|
|
52
|
+
"""Logout clears session files."""
|
|
53
|
+
config = Config(
|
|
54
|
+
apple_id="test@icloud.com",
|
|
55
|
+
session_dir=str(tmp_path / "session"),
|
|
56
|
+
config_file=tmp_path / "config.toml",
|
|
57
|
+
)
|
|
58
|
+
session_dir = tmp_path / "session"
|
|
59
|
+
session_dir.mkdir(parents=True)
|
|
60
|
+
(session_dir / "cookie1").write_text("data")
|
|
61
|
+
(session_dir / "cookie2").write_text("data")
|
|
62
|
+
|
|
63
|
+
mock_keyring.delete_password.return_value = None
|
|
64
|
+
|
|
65
|
+
auth = AuthManager(config)
|
|
66
|
+
auth.logout()
|
|
67
|
+
|
|
68
|
+
# Session files should be cleared
|
|
69
|
+
assert list(session_dir.iterdir()) == []
|
|
70
|
+
|
|
71
|
+
@patch("icloud_cli.auth.keyring")
|
|
72
|
+
def test_has_no_cached_session(self, mock_keyring, tmp_path):
|
|
73
|
+
"""Reports no session when session dir is empty."""
|
|
74
|
+
config = Config(
|
|
75
|
+
apple_id="test@icloud.com",
|
|
76
|
+
session_dir=str(tmp_path / "session"),
|
|
77
|
+
config_file=tmp_path / "config.toml",
|
|
78
|
+
)
|
|
79
|
+
(tmp_path / "session").mkdir(parents=True)
|
|
80
|
+
|
|
81
|
+
mock_keyring.get_password.return_value = None
|
|
82
|
+
|
|
83
|
+
auth = AuthManager(config)
|
|
84
|
+
assert not auth._has_cached_session()
|
|
85
|
+
|
|
86
|
+
@patch("icloud_cli.auth.keyring")
|
|
87
|
+
def test_imap_credentials_not_set(self, mock_keyring, tmp_path):
|
|
88
|
+
"""Returns None when IMAP credentials are not configured."""
|
|
89
|
+
config = Config(
|
|
90
|
+
apple_id="test@icloud.com",
|
|
91
|
+
session_dir=str(tmp_path / "session"),
|
|
92
|
+
config_file=tmp_path / "config.toml",
|
|
93
|
+
)
|
|
94
|
+
mock_keyring.get_password.return_value = None
|
|
95
|
+
|
|
96
|
+
auth = AuthManager(config)
|
|
97
|
+
assert auth.get_imap_credentials() is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestHandle2FA:
|
|
101
|
+
"""Tests for the interactive 2FA prompt flow."""
|
|
102
|
+
|
|
103
|
+
def _make_auth(self, tmp_path, api):
|
|
104
|
+
config = Config(
|
|
105
|
+
apple_id="test@icloud.com",
|
|
106
|
+
session_dir=str(tmp_path / "session"),
|
|
107
|
+
config_file=tmp_path / "config.toml",
|
|
108
|
+
)
|
|
109
|
+
auth = AuthManager(config)
|
|
110
|
+
auth._api = api
|
|
111
|
+
return auth
|
|
112
|
+
|
|
113
|
+
@patch("icloud_cli.auth.click")
|
|
114
|
+
def test_security_key_challenge_does_not_prompt(self, mock_click, tmp_path):
|
|
115
|
+
"""When request_2fa_code() returns False, no code is requested."""
|
|
116
|
+
api = MagicMock()
|
|
117
|
+
api.request_2fa_code.return_value = False
|
|
118
|
+
api.two_factor_delivery_method = "security_key"
|
|
119
|
+
auth = self._make_auth(tmp_path, api)
|
|
120
|
+
|
|
121
|
+
assert auth._handle_2fa() is False
|
|
122
|
+
api.request_2fa_code.assert_called_once()
|
|
123
|
+
# No code should be prompted for or validated when nothing was delivered.
|
|
124
|
+
mock_click.prompt.assert_not_called()
|
|
125
|
+
api.validate_2fa_code.assert_not_called()
|
|
126
|
+
|
|
127
|
+
@patch("icloud_cli.auth.error")
|
|
128
|
+
@patch("icloud_cli.auth.click")
|
|
129
|
+
def test_missing_delivery_channel_has_accurate_error(
|
|
130
|
+
self, mock_click, mock_error, tmp_path
|
|
131
|
+
):
|
|
132
|
+
"""A missing delivery route is not misreported as a security-key challenge."""
|
|
133
|
+
api = MagicMock()
|
|
134
|
+
api.request_2fa_code.return_value = False
|
|
135
|
+
api.two_factor_delivery_method = "unknown"
|
|
136
|
+
auth = self._make_auth(tmp_path, api)
|
|
137
|
+
|
|
138
|
+
assert auth._handle_2fa() is False
|
|
139
|
+
message = str(mock_error.call_args.args[0]).lower()
|
|
140
|
+
assert "delivery channel" in message
|
|
141
|
+
assert "hardware security key" not in message
|
|
142
|
+
mock_click.prompt.assert_not_called()
|
|
143
|
+
|
|
144
|
+
@patch("icloud_cli.auth.click")
|
|
145
|
+
def test_trusted_device_success(self, mock_click, tmp_path):
|
|
146
|
+
"""A valid trusted-device code trusts the session and succeeds."""
|
|
147
|
+
api = MagicMock()
|
|
148
|
+
api.request_2fa_code.return_value = True
|
|
149
|
+
api.two_factor_delivery_method = "trusted_device"
|
|
150
|
+
api.two_factor_delivery_notice = None
|
|
151
|
+
api.validate_2fa_code.return_value = True
|
|
152
|
+
api.is_trusted_session = False
|
|
153
|
+
auth = self._make_auth(tmp_path, api)
|
|
154
|
+
mock_click.prompt.return_value = "123456"
|
|
155
|
+
|
|
156
|
+
assert auth._handle_2fa() is True
|
|
157
|
+
api.validate_2fa_code.assert_called_once_with("123456")
|
|
158
|
+
api.trust_session.assert_called_once()
|
|
159
|
+
|
|
160
|
+
@patch("icloud_cli.auth.info")
|
|
161
|
+
@patch("icloud_cli.auth.click")
|
|
162
|
+
def test_prompt_text_matches_delivery_channel(self, mock_click, mock_info, tmp_path):
|
|
163
|
+
"""The prompt describes the trusted-device channel without an approve step."""
|
|
164
|
+
api = MagicMock()
|
|
165
|
+
api.request_2fa_code.return_value = True
|
|
166
|
+
api.two_factor_delivery_method = "trusted_device"
|
|
167
|
+
api.two_factor_delivery_notice = None
|
|
168
|
+
api.validate_2fa_code.return_value = True
|
|
169
|
+
api.is_trusted_session = True
|
|
170
|
+
auth = self._make_auth(tmp_path, api)
|
|
171
|
+
mock_click.prompt.return_value = "123456"
|
|
172
|
+
|
|
173
|
+
auth._handle_2fa()
|
|
174
|
+
|
|
175
|
+
messages = " ".join(str(c.args[0]) for c in mock_info.call_args_list).lower()
|
|
176
|
+
assert "approve" not in messages
|
|
177
|
+
assert "trusted device" in messages
|
|
178
|
+
|
|
179
|
+
@patch("icloud_cli.auth.click")
|
|
180
|
+
def test_retries_then_succeeds(self, mock_click, tmp_path):
|
|
181
|
+
"""A mistyped code is retried instead of forcing a re-login."""
|
|
182
|
+
api = MagicMock()
|
|
183
|
+
api.request_2fa_code.return_value = True
|
|
184
|
+
api.two_factor_delivery_method = "sms"
|
|
185
|
+
api.two_factor_delivery_notice = None
|
|
186
|
+
api.validate_2fa_code.side_effect = [False, True]
|
|
187
|
+
api.is_trusted_session = True
|
|
188
|
+
auth = self._make_auth(tmp_path, api)
|
|
189
|
+
mock_click.prompt.return_value = "000000"
|
|
190
|
+
|
|
191
|
+
assert auth._handle_2fa() is True
|
|
192
|
+
assert api.validate_2fa_code.call_count == 2
|
|
193
|
+
|
|
194
|
+
@patch("icloud_cli.auth.click")
|
|
195
|
+
def test_invalid_code_exhausts_attempts(self, mock_click, tmp_path):
|
|
196
|
+
"""After three wrong codes the flow fails without trusting the session."""
|
|
197
|
+
api = MagicMock()
|
|
198
|
+
api.request_2fa_code.return_value = True
|
|
199
|
+
api.two_factor_delivery_method = "trusted_device"
|
|
200
|
+
api.two_factor_delivery_notice = None
|
|
201
|
+
api.validate_2fa_code.return_value = False
|
|
202
|
+
auth = self._make_auth(tmp_path, api)
|
|
203
|
+
mock_click.prompt.return_value = "000000"
|
|
204
|
+
|
|
205
|
+
assert auth._handle_2fa() is False
|
|
206
|
+
assert api.validate_2fa_code.call_count == 3
|
|
207
|
+
api.trust_session.assert_not_called()
|
|
@@ -5,9 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from unittest.mock import MagicMock, patch
|
|
7
7
|
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
8
10
|
from icloud_cli.services.reminders import (
|
|
9
11
|
RemindersService,
|
|
10
12
|
_format_due_date,
|
|
13
|
+
_parse_due_date,
|
|
11
14
|
_reminders_color_payload,
|
|
12
15
|
)
|
|
13
16
|
|
|
@@ -51,14 +54,53 @@ class TestFormatDueDate:
|
|
|
51
54
|
result = _format_due_date(None)
|
|
52
55
|
assert result == "None"
|
|
53
56
|
|
|
57
|
+
def test_format_date_only_omits_time(self):
|
|
58
|
+
result = _format_due_date(datetime(2025, 6, 15, 14, 30), date_only=True)
|
|
59
|
+
assert result == "2025-06-15"
|
|
60
|
+
|
|
61
|
+
def test_format_date_only_list(self):
|
|
62
|
+
result = _format_due_date([2025, 6, 15, 14, 30], date_only=True)
|
|
63
|
+
assert result == "2025-06-15"
|
|
64
|
+
|
|
54
65
|
def test_color_payload_defaults_unknown_color_to_blue(self):
|
|
55
66
|
assert _reminders_color_payload("wat") == _reminders_color_payload("blue")
|
|
56
67
|
|
|
57
68
|
|
|
69
|
+
class TestParseDueDate:
|
|
70
|
+
"""Tests for due-date parsing and all-day detection."""
|
|
71
|
+
|
|
72
|
+
def test_bare_date_is_all_day_anchored_to_noon(self):
|
|
73
|
+
due, all_day = _parse_due_date("2025-06-15")
|
|
74
|
+
assert all_day is True
|
|
75
|
+
assert (due.year, due.month, due.day) == (2025, 6, 15)
|
|
76
|
+
assert (due.hour, due.minute, due.second) == (12, 0, 0)
|
|
77
|
+
|
|
78
|
+
def test_date_with_time_is_not_all_day(self):
|
|
79
|
+
due, all_day = _parse_due_date("2025-06-15 14:30")
|
|
80
|
+
assert all_day is False
|
|
81
|
+
assert (due.hour, due.minute) == (14, 30)
|
|
82
|
+
|
|
83
|
+
def test_explicit_midnight_is_not_all_day(self):
|
|
84
|
+
# A user who explicitly types 00:00 wants a timed reminder.
|
|
85
|
+
due, all_day = _parse_due_date("2025-06-15 00:00")
|
|
86
|
+
assert all_day is False
|
|
87
|
+
assert (due.hour, due.minute) == (0, 0)
|
|
88
|
+
|
|
89
|
+
def test_year_omitted_defaults_to_current_year(self):
|
|
90
|
+
# Regression: a date without a year should fall back to the current
|
|
91
|
+
# year, not a hardcoded one.
|
|
92
|
+
due, all_day = _parse_due_date("June 15")
|
|
93
|
+
assert all_day is True
|
|
94
|
+
assert due.year == datetime.now().year
|
|
95
|
+
assert (due.month, due.day) == (6, 15)
|
|
96
|
+
|
|
97
|
+
|
|
58
98
|
class TestRemindersService:
|
|
59
99
|
"""Tests for RemindersService with mocked API."""
|
|
60
100
|
|
|
61
101
|
def _make_service(self, mock_api, mock_config):
|
|
102
|
+
mock_api.reminders.sync_cursor.return_value = "cursor-1"
|
|
103
|
+
mock_api.reminders.iter_changes.return_value = []
|
|
62
104
|
return RemindersService(mock_api, mock_config)
|
|
63
105
|
|
|
64
106
|
def _make_list_model(self, list_id="list-1", title="Personal"):
|
|
@@ -83,8 +125,17 @@ class TestRemindersService:
|
|
|
83
125
|
r.desc = desc
|
|
84
126
|
r.list_id = "list-1"
|
|
85
127
|
r.flagged = False
|
|
128
|
+
r.all_day = False
|
|
129
|
+
r.deleted = False
|
|
86
130
|
return r
|
|
87
131
|
|
|
132
|
+
def _make_change(self, reminder=None, change_type="updated", reminder_id=None):
|
|
133
|
+
change = MagicMock()
|
|
134
|
+
change.type = change_type
|
|
135
|
+
change.reminder = reminder
|
|
136
|
+
change.reminder_id = reminder_id or reminder.id
|
|
137
|
+
return change
|
|
138
|
+
|
|
88
139
|
def test_list_reminders_empty(self, mock_api, mock_config):
|
|
89
140
|
mock_api.reminders.lists.return_value = []
|
|
90
141
|
mock_api.reminders.reminders.return_value = []
|
|
@@ -110,6 +161,32 @@ class TestRemindersService:
|
|
|
110
161
|
assert result[0]["list"] == "Personal"
|
|
111
162
|
assert result[0]["priority"] == "High"
|
|
112
163
|
|
|
164
|
+
def test_list_reminders_all_day_omits_time(self, mock_api, mock_config):
|
|
165
|
+
lst = self._make_list_model()
|
|
166
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
167
|
+
|
|
168
|
+
reminder = self._make_reminder_model(due_date=datetime(2025, 6, 15, 12, 0))
|
|
169
|
+
reminder.all_day = True
|
|
170
|
+
mock_api.reminders.reminders.return_value = [reminder]
|
|
171
|
+
|
|
172
|
+
service = self._make_service(mock_api, mock_config)
|
|
173
|
+
result = service.list_reminders()
|
|
174
|
+
|
|
175
|
+
assert result[0]["due_date"] == "2025-06-15"
|
|
176
|
+
|
|
177
|
+
def test_list_reminders_timed_shows_time(self, mock_api, mock_config):
|
|
178
|
+
lst = self._make_list_model()
|
|
179
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
180
|
+
|
|
181
|
+
reminder = self._make_reminder_model(due_date=datetime(2025, 6, 15, 9, 30))
|
|
182
|
+
reminder.all_day = False
|
|
183
|
+
mock_api.reminders.reminders.return_value = [reminder]
|
|
184
|
+
|
|
185
|
+
service = self._make_service(mock_api, mock_config)
|
|
186
|
+
result = service.list_reminders()
|
|
187
|
+
|
|
188
|
+
assert result[0]["due_date"] == "2025-06-15 09:30"
|
|
189
|
+
|
|
113
190
|
def test_list_reminders_filters_completed(self, mock_api, mock_config):
|
|
114
191
|
lst = self._make_list_model(list_id="list-1", title="Work")
|
|
115
192
|
mock_api.reminders.lists.return_value = [lst]
|
|
@@ -133,6 +210,92 @@ class TestRemindersService:
|
|
|
133
210
|
result = service.list_reminders(show_completed=True)
|
|
134
211
|
assert len(result) == 2
|
|
135
212
|
|
|
213
|
+
def test_empty_snapshot_uses_incremental_sync_next_time(self, mock_api, mock_config):
|
|
214
|
+
mock_api.reminders.lists.return_value = []
|
|
215
|
+
service = self._make_service(mock_api, mock_config)
|
|
216
|
+
|
|
217
|
+
assert service.list_reminders() == []
|
|
218
|
+
assert service.list_reminders() == []
|
|
219
|
+
|
|
220
|
+
mock_api.reminders.iter_changes.assert_called_once_with(since="cursor-1")
|
|
221
|
+
|
|
222
|
+
def test_incremental_sync_applies_updates_and_deletions(self, mock_api, mock_config):
|
|
223
|
+
lst = self._make_list_model()
|
|
224
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
225
|
+
old = self._make_reminder_model(reminder_id="rem-1", title="Old title")
|
|
226
|
+
removed = self._make_reminder_model(reminder_id="rem-2", title="Remove me")
|
|
227
|
+
mock_api.reminders.reminders.return_value = [old, removed]
|
|
228
|
+
service = self._make_service(mock_api, mock_config)
|
|
229
|
+
assert len(service.list_reminders()) == 2
|
|
230
|
+
|
|
231
|
+
updated = self._make_reminder_model(reminder_id="rem-1", title="New title")
|
|
232
|
+
mock_api.reminders.iter_changes.return_value = [
|
|
233
|
+
self._make_change(updated),
|
|
234
|
+
self._make_change(None, "deleted", "rem-2"),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
result = service.list_reminders()
|
|
238
|
+
|
|
239
|
+
assert [entry["title"] for entry in result] == ["New title"]
|
|
240
|
+
|
|
241
|
+
def test_list_rename_refreshes_cached_entries(self, mock_api, mock_config):
|
|
242
|
+
original_list = self._make_list_model(title="Personal")
|
|
243
|
+
mock_api.reminders.lists.return_value = [original_list]
|
|
244
|
+
mock_api.reminders.reminders.return_value = [self._make_reminder_model()]
|
|
245
|
+
service = self._make_service(mock_api, mock_config)
|
|
246
|
+
assert service.list_reminders()[0]["list"] == "Personal"
|
|
247
|
+
|
|
248
|
+
renamed_list = self._make_list_model(title="Renamed")
|
|
249
|
+
mock_api.reminders.lists.return_value = [renamed_list]
|
|
250
|
+
|
|
251
|
+
assert service.list_reminders()[0]["list"] == "Renamed"
|
|
252
|
+
|
|
253
|
+
def test_cursor_is_captured_before_incremental_read(self, mock_api, mock_config):
|
|
254
|
+
lst = self._make_list_model()
|
|
255
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
256
|
+
mock_api.reminders.reminders.return_value = [self._make_reminder_model()]
|
|
257
|
+
service = self._make_service(mock_api, mock_config)
|
|
258
|
+
service.list_reminders()
|
|
259
|
+
mock_api.reminders.reset_mock()
|
|
260
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
261
|
+
mock_api.reminders.sync_cursor.return_value = "cursor-2"
|
|
262
|
+
mock_api.reminders.iter_changes.return_value = []
|
|
263
|
+
|
|
264
|
+
service.list_reminders()
|
|
265
|
+
|
|
266
|
+
method_names = [call[0] for call in mock_api.reminders.method_calls]
|
|
267
|
+
assert method_names.index("sync_cursor") < method_names.index("iter_changes")
|
|
268
|
+
|
|
269
|
+
def test_invalid_cursor_falls_back_to_full_snapshot(self, mock_api, mock_config):
|
|
270
|
+
lst = self._make_list_model()
|
|
271
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
272
|
+
original = self._make_reminder_model(title="Original")
|
|
273
|
+
mock_api.reminders.reminders.return_value = [original]
|
|
274
|
+
service = self._make_service(mock_api, mock_config)
|
|
275
|
+
service.list_reminders()
|
|
276
|
+
|
|
277
|
+
refreshed = self._make_reminder_model(title="Refreshed")
|
|
278
|
+
mock_api.reminders.reminders.return_value = [refreshed]
|
|
279
|
+
mock_api.reminders.iter_changes.side_effect = RuntimeError("expired cursor")
|
|
280
|
+
|
|
281
|
+
result = service.list_reminders()
|
|
282
|
+
|
|
283
|
+
assert result[0]["title"] == "Refreshed"
|
|
284
|
+
|
|
285
|
+
def test_atomic_state_failure_preserves_previous_generation(
|
|
286
|
+
self, mock_api, mock_config
|
|
287
|
+
):
|
|
288
|
+
service = self._make_service(mock_api, mock_config)
|
|
289
|
+
service._write_sync_state("cursor-1", [])
|
|
290
|
+
|
|
291
|
+
with (
|
|
292
|
+
patch("pathlib.Path.replace", side_effect=OSError("disk full")),
|
|
293
|
+
pytest.raises(OSError),
|
|
294
|
+
):
|
|
295
|
+
service._write_sync_state("cursor-2", [{"id": "rem-1"}])
|
|
296
|
+
|
|
297
|
+
assert service._load_sync_state() == ("cursor-1", [])
|
|
298
|
+
|
|
136
299
|
def test_add_reminder_success(self, mock_api, mock_config):
|
|
137
300
|
lst = self._make_list_model()
|
|
138
301
|
mock_api.reminders.lists.return_value = [lst]
|
|
@@ -142,6 +305,45 @@ class TestRemindersService:
|
|
|
142
305
|
assert service.add_reminder(title="New task") is True
|
|
143
306
|
mock_api.reminders.create.assert_called_once()
|
|
144
307
|
|
|
308
|
+
def test_add_reminder_bare_date_is_all_day(self, mock_api, mock_config):
|
|
309
|
+
lst = self._make_list_model()
|
|
310
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
311
|
+
mock_api.reminders.create.return_value = MagicMock()
|
|
312
|
+
|
|
313
|
+
service = self._make_service(mock_api, mock_config)
|
|
314
|
+
assert service.add_reminder(title="Pay rent", due_date="2025-06-15") is True
|
|
315
|
+
|
|
316
|
+
call_kwargs = mock_api.reminders.create.call_args[1]
|
|
317
|
+
assert call_kwargs["all_day"] is True
|
|
318
|
+
assert call_kwargs["due_date"].hour == 12
|
|
319
|
+
assert (
|
|
320
|
+
call_kwargs["due_date"].year,
|
|
321
|
+
call_kwargs["due_date"].month,
|
|
322
|
+
call_kwargs["due_date"].day,
|
|
323
|
+
) == (2025, 6, 15)
|
|
324
|
+
|
|
325
|
+
def test_add_reminder_with_time_is_not_all_day(self, mock_api, mock_config):
|
|
326
|
+
lst = self._make_list_model()
|
|
327
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
328
|
+
mock_api.reminders.create.return_value = MagicMock()
|
|
329
|
+
|
|
330
|
+
service = self._make_service(mock_api, mock_config)
|
|
331
|
+
assert (
|
|
332
|
+
service.add_reminder(title="Standup", due_date="2025-06-15 09:30") is True
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
call_kwargs = mock_api.reminders.create.call_args[1]
|
|
336
|
+
assert call_kwargs["all_day"] is False
|
|
337
|
+
assert (call_kwargs["due_date"].hour, call_kwargs["due_date"].minute) == (9, 30)
|
|
338
|
+
|
|
339
|
+
def test_add_reminder_invalid_date_returns_false(self, mock_api, mock_config):
|
|
340
|
+
lst = self._make_list_model()
|
|
341
|
+
mock_api.reminders.lists.return_value = [lst]
|
|
342
|
+
|
|
343
|
+
service = self._make_service(mock_api, mock_config)
|
|
344
|
+
assert service.add_reminder(title="Bad", due_date="not-a-date") is False
|
|
345
|
+
mock_api.reminders.create.assert_not_called()
|
|
346
|
+
|
|
145
347
|
def test_add_reminder_with_list_name(self, mock_api, mock_config):
|
|
146
348
|
lst1 = self._make_list_model(list_id="list-1", title="Personal")
|
|
147
349
|
lst2 = self._make_list_model(list_id="list-2", title="Shopping")
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
"""Tests for the auth module."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from unittest.mock import patch
|
|
6
|
-
|
|
7
|
-
from icloud_cli.auth import AuthManager
|
|
8
|
-
from icloud_cli.config import Config
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestAuthManager:
|
|
12
|
-
"""Tests for AuthManager."""
|
|
13
|
-
|
|
14
|
-
def test_get_status_not_logged_in(self, tmp_path):
|
|
15
|
-
"""Status shows not logged in when no credentials stored."""
|
|
16
|
-
config = Config(
|
|
17
|
-
apple_id="",
|
|
18
|
-
session_dir=str(tmp_path / "session"),
|
|
19
|
-
config_file=tmp_path / "config.toml",
|
|
20
|
-
)
|
|
21
|
-
auth = AuthManager(config)
|
|
22
|
-
status = auth.get_status()
|
|
23
|
-
|
|
24
|
-
assert status["apple_id"] == "(not set)"
|
|
25
|
-
assert status["password_stored"] == "No"
|
|
26
|
-
assert status["session_cached"] == "No"
|
|
27
|
-
|
|
28
|
-
@patch("icloud_cli.auth.keyring")
|
|
29
|
-
def test_get_status_logged_in(self, mock_keyring, tmp_path):
|
|
30
|
-
"""Status shows logged in when credentials are stored."""
|
|
31
|
-
config = Config(
|
|
32
|
-
apple_id="test@icloud.com",
|
|
33
|
-
session_dir=str(tmp_path / "session"),
|
|
34
|
-
config_file=tmp_path / "config.toml",
|
|
35
|
-
)
|
|
36
|
-
# Create a session file
|
|
37
|
-
session_dir = tmp_path / "session"
|
|
38
|
-
session_dir.mkdir(parents=True)
|
|
39
|
-
(session_dir / "session_cookie").write_text("fake_cookie")
|
|
40
|
-
|
|
41
|
-
mock_keyring.get_password.return_value = "stored_password"
|
|
42
|
-
|
|
43
|
-
auth = AuthManager(config)
|
|
44
|
-
status = auth.get_status()
|
|
45
|
-
|
|
46
|
-
assert status["apple_id"] == "test@icloud.com"
|
|
47
|
-
assert status["password_stored"] == "Yes"
|
|
48
|
-
assert status["session_cached"] == "Yes"
|
|
49
|
-
|
|
50
|
-
@patch("icloud_cli.auth.keyring")
|
|
51
|
-
def test_logout_clears_session(self, mock_keyring, tmp_path):
|
|
52
|
-
"""Logout clears session files."""
|
|
53
|
-
config = Config(
|
|
54
|
-
apple_id="test@icloud.com",
|
|
55
|
-
session_dir=str(tmp_path / "session"),
|
|
56
|
-
config_file=tmp_path / "config.toml",
|
|
57
|
-
)
|
|
58
|
-
session_dir = tmp_path / "session"
|
|
59
|
-
session_dir.mkdir(parents=True)
|
|
60
|
-
(session_dir / "cookie1").write_text("data")
|
|
61
|
-
(session_dir / "cookie2").write_text("data")
|
|
62
|
-
|
|
63
|
-
mock_keyring.delete_password.return_value = None
|
|
64
|
-
|
|
65
|
-
auth = AuthManager(config)
|
|
66
|
-
auth.logout()
|
|
67
|
-
|
|
68
|
-
# Session files should be cleared
|
|
69
|
-
assert list(session_dir.iterdir()) == []
|
|
70
|
-
|
|
71
|
-
@patch("icloud_cli.auth.keyring")
|
|
72
|
-
def test_has_no_cached_session(self, mock_keyring, tmp_path):
|
|
73
|
-
"""Reports no session when session dir is empty."""
|
|
74
|
-
config = Config(
|
|
75
|
-
apple_id="test@icloud.com",
|
|
76
|
-
session_dir=str(tmp_path / "session"),
|
|
77
|
-
config_file=tmp_path / "config.toml",
|
|
78
|
-
)
|
|
79
|
-
(tmp_path / "session").mkdir(parents=True)
|
|
80
|
-
|
|
81
|
-
mock_keyring.get_password.return_value = None
|
|
82
|
-
|
|
83
|
-
auth = AuthManager(config)
|
|
84
|
-
assert not auth._has_cached_session()
|
|
85
|
-
|
|
86
|
-
@patch("icloud_cli.auth.keyring")
|
|
87
|
-
def test_imap_credentials_not_set(self, mock_keyring, tmp_path):
|
|
88
|
-
"""Returns None when IMAP credentials are not configured."""
|
|
89
|
-
config = Config(
|
|
90
|
-
apple_id="test@icloud.com",
|
|
91
|
-
session_dir=str(tmp_path / "session"),
|
|
92
|
-
config_file=tmp_path / "config.toml",
|
|
93
|
-
)
|
|
94
|
-
mock_keyring.get_password.return_value = None
|
|
95
|
-
|
|
96
|
-
auth = AuthManager(config)
|
|
97
|
-
assert auth.get_imap_credentials() is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/requires.txt
RENAMED
|
File without changes
|
{icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|