icloud-cli-tools 0.1.1__tar.gz → 0.1.2__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.1/src/icloud_cli_tools.egg-info → icloud_cli_tools-0.1.2}/PKG-INFO +2 -2
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/pyproject.toml +2 -2
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/__init__.py +1 -1
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/auth.py +20 -2
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/main.py +56 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/services/calendar.py +69 -21
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/services/findmy.py +5 -5
- icloud_cli_tools-0.1.2/src/icloud_cli/services/reminders.py +409 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2/src/icloud_cli_tools.egg-info}/PKG-INFO +2 -2
- icloud_cli_tools-0.1.2/tests/test_calendar.py +177 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/tests/test_findmy.py +21 -10
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/tests/test_reminders.py +103 -2
- icloud_cli_tools-0.1.1/src/icloud_cli/services/reminders.py +0 -209
- icloud_cli_tools-0.1.1/tests/test_calendar.py +0 -104
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/.github/workflows/ci.yml +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/.gitignore +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/LICENSE +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/README.md +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/setup.cfg +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/config.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/daemon.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/output.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/services/__init__.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli/services/notes.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli_tools.egg-info/SOURCES.txt +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli_tools.egg-info/dependency_links.txt +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli_tools.egg-info/entry_points.txt +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli_tools.egg-info/requires.txt +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/src/icloud_cli_tools.egg-info/top_level.txt +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/systemd/icloud-cli-sync.service +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/tests/conftest.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/tests/test_auth.py +0 -0
- {icloud_cli_tools-0.1.1 → icloud_cli_tools-0.1.2}/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.2
|
|
4
4
|
Summary: Access iCloud Calendar, Reminders, Notes, and Find My from Linux
|
|
5
5
|
Author: alan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
20
|
Classifier: Topic :: Utilities
|
|
21
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: pyicloud>=2.5.0
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "icloud-cli-tools"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Access iCloud Calendar, Reminders, Notes, and Find My from Linux"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
12
|
authors = [{ name = "alan" }]
|
|
13
13
|
keywords = [
|
|
14
14
|
"icloud",
|
|
@@ -89,7 +89,26 @@ class AuthManager:
|
|
|
89
89
|
def _handle_2fa(self) -> bool:
|
|
90
90
|
"""Handle two-factor authentication (trusted device code)."""
|
|
91
91
|
info("Two-factor authentication required.")
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
self._api.request_2fa_code()
|
|
95
|
+
except Exception as e:
|
|
96
|
+
error(f"Failed to trigger 2FA delivery: {e}")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
method = self._api.two_factor_delivery_method
|
|
100
|
+
notice = self._api.two_factor_delivery_notice
|
|
101
|
+
|
|
102
|
+
if method == "trusted_device":
|
|
103
|
+
info("Approve the sign-in request on your trusted device.")
|
|
104
|
+
info("Enter the 6-digit code shown after you approve.")
|
|
105
|
+
elif method == "sms":
|
|
106
|
+
info("A verification code has been sent via SMS.")
|
|
107
|
+
else:
|
|
108
|
+
info("A verification code has been sent to your trusted devices.")
|
|
109
|
+
|
|
110
|
+
if notice:
|
|
111
|
+
warning(notice)
|
|
93
112
|
|
|
94
113
|
code = click.prompt("Enter 2FA code")
|
|
95
114
|
|
|
@@ -99,7 +118,6 @@ class AuthManager:
|
|
|
99
118
|
error("Invalid 2FA code.")
|
|
100
119
|
return False
|
|
101
120
|
|
|
102
|
-
# Trust this session
|
|
103
121
|
if not self._api.is_trusted_session:
|
|
104
122
|
info("Trusting this session...")
|
|
105
123
|
self._api.trust_session()
|
|
@@ -227,6 +227,62 @@ def reminders_add(
|
|
|
227
227
|
error("Failed to create reminder.")
|
|
228
228
|
|
|
229
229
|
|
|
230
|
+
@reminders.group("lists")
|
|
231
|
+
def reminders_lists():
|
|
232
|
+
"""Manage reminder lists."""
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@reminders_lists.command("list")
|
|
237
|
+
@pass_context
|
|
238
|
+
def reminders_lists_list(ctx: AppContext):
|
|
239
|
+
"""List reminder lists."""
|
|
240
|
+
from icloud_cli.services.reminders import RemindersService
|
|
241
|
+
|
|
242
|
+
service = RemindersService(ctx.auth.api, ctx.config)
|
|
243
|
+
items = service.list_reminder_lists()
|
|
244
|
+
render(items, format=ctx.format, title="Reminder Lists",
|
|
245
|
+
columns=["title", "count", "color", "id"])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@reminders_lists.command("create")
|
|
249
|
+
@click.argument("name")
|
|
250
|
+
@click.option("--color", default="blue", help="List color.")
|
|
251
|
+
@pass_context
|
|
252
|
+
def reminders_lists_create(ctx: AppContext, name: str, color: str):
|
|
253
|
+
"""Create a reminder list."""
|
|
254
|
+
from icloud_cli.services.reminders import RemindersService
|
|
255
|
+
|
|
256
|
+
service = RemindersService(ctx.auth.api, ctx.config)
|
|
257
|
+
list_id = service.create_list(name, color=color)
|
|
258
|
+
if list_id:
|
|
259
|
+
success(f"Reminder list '{name}' created.")
|
|
260
|
+
else:
|
|
261
|
+
error(f"Failed to create reminder list '{name}'.")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@reminders.group("sections")
|
|
265
|
+
def reminders_sections():
|
|
266
|
+
"""Manage reminder list sections."""
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@reminders_sections.command("create")
|
|
271
|
+
@click.argument("list_name")
|
|
272
|
+
@click.argument("section_name")
|
|
273
|
+
@pass_context
|
|
274
|
+
def reminders_sections_create(ctx: AppContext, list_name: str, section_name: str):
|
|
275
|
+
"""Create a section in a reminder list."""
|
|
276
|
+
from icloud_cli.services.reminders import RemindersService
|
|
277
|
+
|
|
278
|
+
service = RemindersService(ctx.auth.api, ctx.config)
|
|
279
|
+
section_id = service.create_section(list_name=list_name, section_name=section_name)
|
|
280
|
+
if section_id:
|
|
281
|
+
success(f"Reminder section '{section_name}' created.")
|
|
282
|
+
else:
|
|
283
|
+
error(f"Failed to create reminder section '{section_name}'.")
|
|
284
|
+
|
|
285
|
+
|
|
230
286
|
@reminders.command("complete")
|
|
231
287
|
@click.argument("reminder_id")
|
|
232
288
|
@pass_context
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from dateutil import parser as dateutil_parser
|
|
12
12
|
from pyicloud import PyiCloudService
|
|
13
|
+
from pyicloud.services.calendar import EventObject
|
|
13
14
|
|
|
14
15
|
from icloud_cli.config import Config
|
|
15
16
|
|
|
@@ -47,6 +48,23 @@ def _format_datetime(dt: Any) -> str:
|
|
|
47
48
|
return dt
|
|
48
49
|
if isinstance(dt, datetime):
|
|
49
50
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
51
|
+
if isinstance(dt, list) and len(dt) >= 4:
|
|
52
|
+
# Calendar events come back as Apple's raw, packed-first list
|
|
53
|
+
# (pyicloud's get_events passes the response through untransformed):
|
|
54
|
+
# [packed_str, year, month, day, hour, minute, tz_offset_minutes]
|
|
55
|
+
# (Reminders use pyicloud's typed API and return datetimes, not lists,
|
|
56
|
+
# so this function only ever sees the packed-first calendar shape.)
|
|
57
|
+
try:
|
|
58
|
+
parsed = datetime(
|
|
59
|
+
dt[1],
|
|
60
|
+
dt[2],
|
|
61
|
+
dt[3],
|
|
62
|
+
dt[4] if len(dt) > 4 else 0,
|
|
63
|
+
dt[5] if len(dt) > 5 else 0,
|
|
64
|
+
)
|
|
65
|
+
return parsed.strftime("%Y-%m-%d %H:%M")
|
|
66
|
+
except (IndexError, TypeError, ValueError):
|
|
67
|
+
return str(dt)
|
|
50
68
|
if isinstance(dt, (int, float)):
|
|
51
69
|
# Unix timestamp in milliseconds
|
|
52
70
|
try:
|
|
@@ -80,7 +98,11 @@ class CalendarService:
|
|
|
80
98
|
end = _parse_date(to_date, default=start + timedelta(days=7))
|
|
81
99
|
|
|
82
100
|
try:
|
|
83
|
-
|
|
101
|
+
cal_name = {
|
|
102
|
+
cal.get("guid"): cal.get("title", "")
|
|
103
|
+
for cal in self.api.calendar.get_calendars()
|
|
104
|
+
}
|
|
105
|
+
events = self.api.calendar.get_events(from_dt=start, to_dt=end)
|
|
84
106
|
except Exception as e:
|
|
85
107
|
from icloud_cli.output import error
|
|
86
108
|
error(f"Failed to fetch events: {e}")
|
|
@@ -88,6 +110,7 @@ class CalendarService:
|
|
|
88
110
|
|
|
89
111
|
result = []
|
|
90
112
|
for event in events:
|
|
113
|
+
pguid = event.get("pGuid", "")
|
|
91
114
|
result.append({
|
|
92
115
|
"id": event.get("guid", ""),
|
|
93
116
|
"title": event.get("title", "Untitled"),
|
|
@@ -97,7 +120,7 @@ class CalendarService:
|
|
|
97
120
|
"end": _format_datetime(
|
|
98
121
|
event.get("endDate") or event.get("localEndDate")
|
|
99
122
|
),
|
|
100
|
-
"calendar":
|
|
123
|
+
"calendar": cal_name.get(pguid, pguid),
|
|
101
124
|
"location": event.get("location", ""),
|
|
102
125
|
"all_day": event.get("allDay", False),
|
|
103
126
|
})
|
|
@@ -120,7 +143,7 @@ class CalendarService:
|
|
|
120
143
|
end = now + timedelta(days=365)
|
|
121
144
|
|
|
122
145
|
try:
|
|
123
|
-
events = self.api.calendar.
|
|
146
|
+
events = self.api.calendar.get_events(from_dt=start, to_dt=end)
|
|
124
147
|
except Exception:
|
|
125
148
|
return None
|
|
126
149
|
|
|
@@ -175,19 +198,33 @@ class CalendarService:
|
|
|
175
198
|
return False
|
|
176
199
|
|
|
177
200
|
try:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
201
|
+
calendars = self.api.calendar.get_calendars()
|
|
202
|
+
cal_guid = None
|
|
203
|
+
if calendar_name:
|
|
204
|
+
for cal in calendars:
|
|
205
|
+
if cal.get("title", "").lower() == calendar_name.lower():
|
|
206
|
+
cal_guid = cal.get("guid")
|
|
207
|
+
break
|
|
208
|
+
if not cal_guid and calendars:
|
|
209
|
+
cal_guid = calendars[0].get("guid")
|
|
210
|
+
if not cal_guid:
|
|
211
|
+
from icloud_cli.output import error
|
|
212
|
+
error("No calendars found.")
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
event_obj = EventObject(
|
|
216
|
+
pguid=cal_guid,
|
|
217
|
+
title=title,
|
|
218
|
+
start_date=start_dt,
|
|
219
|
+
end_date=end_dt,
|
|
220
|
+
location=location or "",
|
|
221
|
+
)
|
|
187
222
|
if description:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
# pyicloud's EventObject has no notes/description field, so notes
|
|
224
|
+
# cannot be persisted. Warn rather than dropping them silently.
|
|
225
|
+
from icloud_cli.output import warning
|
|
226
|
+
warning("Event notes are not supported by the backend and were not saved.")
|
|
227
|
+
self.api.calendar.add_event(event_obj)
|
|
191
228
|
return True
|
|
192
229
|
except Exception as e:
|
|
193
230
|
from icloud_cli.output import error
|
|
@@ -204,14 +241,25 @@ class CalendarService:
|
|
|
204
241
|
True if event was deleted successfully.
|
|
205
242
|
"""
|
|
206
243
|
try:
|
|
207
|
-
|
|
244
|
+
now = datetime.now()
|
|
245
|
+
events = self.api.calendar.get_events(
|
|
246
|
+
from_dt=now - timedelta(days=365),
|
|
247
|
+
to_dt=now + timedelta(days=365),
|
|
248
|
+
)
|
|
249
|
+
pguid = None
|
|
250
|
+
for ev in events:
|
|
251
|
+
if ev.get("guid") == event_id:
|
|
252
|
+
pguid = ev.get("pGuid")
|
|
253
|
+
break
|
|
254
|
+
if not pguid:
|
|
255
|
+
from icloud_cli.output import error
|
|
256
|
+
error(f"Event '{event_id}' not found.")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
event_obj = self.api.calendar.get_event_detail(pguid, event_id, as_obj=True)
|
|
260
|
+
self.api.calendar.remove_event(event_obj)
|
|
208
261
|
return True
|
|
209
262
|
except Exception as e:
|
|
210
263
|
from icloud_cli.output import error
|
|
211
264
|
error(f"Failed to delete event: {e}")
|
|
212
265
|
return False
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _datetime_to_icloud(dt: datetime) -> list[int]:
|
|
216
|
-
"""Convert a datetime to iCloud's date format [year, month, day, hour, minute]."""
|
|
217
|
-
return [dt.year, dt.month, dt.day, dt.hour, dt.minute]
|
|
@@ -30,9 +30,9 @@ class FindMyService:
|
|
|
30
30
|
return []
|
|
31
31
|
|
|
32
32
|
result = []
|
|
33
|
-
for device in devices:
|
|
33
|
+
for device in devices.values():
|
|
34
34
|
status_data = device.status()
|
|
35
|
-
location = device.location
|
|
35
|
+
location = device.location
|
|
36
36
|
|
|
37
37
|
loc_str = ""
|
|
38
38
|
if location and location.get("latitude") and location.get("longitude"):
|
|
@@ -69,7 +69,7 @@ class FindMyService:
|
|
|
69
69
|
return None
|
|
70
70
|
|
|
71
71
|
status_data = device.status()
|
|
72
|
-
location = device.location
|
|
72
|
+
location = device.location
|
|
73
73
|
|
|
74
74
|
if not location:
|
|
75
75
|
from icloud_cli.output import warning
|
|
@@ -157,13 +157,13 @@ class FindMyService:
|
|
|
157
157
|
name_lower = name.lower()
|
|
158
158
|
|
|
159
159
|
# Exact match first
|
|
160
|
-
for device in devices:
|
|
160
|
+
for device in devices.values():
|
|
161
161
|
status = device.status()
|
|
162
162
|
if status.get("name", "").lower() == name_lower:
|
|
163
163
|
return device
|
|
164
164
|
|
|
165
165
|
# Partial match
|
|
166
|
-
for device in devices:
|
|
166
|
+
for device in devices.values():
|
|
167
167
|
status = device.status()
|
|
168
168
|
if name_lower in status.get("name", "").lower():
|
|
169
169
|
return device
|