From 366dba94cadc4caa3b55a789fc87909bad8b237a Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Mon, 16 Mar 2026 21:24:30 +0100 Subject: [PATCH 01/18] Refactor CLI into Typer command package --- README.md | 44 +- pyicloud/cli/__init__.py | 5 + pyicloud/cli/app.py | 139 ++++ pyicloud/cli/commands/__init__.py | 1 + pyicloud/cli/commands/account.py | 122 +++ pyicloud/cli/commands/calendar.py | 96 +++ pyicloud/cli/commands/contacts.py | 64 ++ pyicloud/cli/commands/devices.py | 216 +++++ pyicloud/cli/commands/drive.py | 95 +++ pyicloud/cli/commands/hidemyemail.py | 143 ++++ pyicloud/cli/commands/notes.py | 225 +++++ pyicloud/cli/commands/photos.py | 102 +++ pyicloud/cli/commands/reminders.py | 197 +++++ pyicloud/cli/context.py | 315 +++++++ pyicloud/cli/normalize.py | 196 +++++ pyicloud/cli/output.py | 137 +++ pyicloud/cmdline.py | 519 +----------- requirements.txt | 1 + tests/test_cmdline.py | 1153 +++++++++++++++++--------- 19 files changed, 2866 insertions(+), 904 deletions(-) create mode 100644 pyicloud/cli/__init__.py create mode 100644 pyicloud/cli/app.py create mode 100644 pyicloud/cli/commands/__init__.py create mode 100644 pyicloud/cli/commands/account.py create mode 100644 pyicloud/cli/commands/calendar.py create mode 100644 pyicloud/cli/commands/contacts.py create mode 100644 pyicloud/cli/commands/devices.py create mode 100644 pyicloud/cli/commands/drive.py create mode 100644 pyicloud/cli/commands/hidemyemail.py create mode 100644 pyicloud/cli/commands/notes.py create mode 100644 pyicloud/cli/commands/photos.py create mode 100644 pyicloud/cli/commands/reminders.py create mode 100644 pyicloud/cli/context.py create mode 100644 pyicloud/cli/normalize.py create mode 100644 pyicloud/cli/output.py diff --git a/README.md b/README.md index 2007765c..1d5e365f 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,23 @@ api = PyiCloudService('jappleseed@apple.com', 'password', refresh_interval=60) # api.devices ``` -You can also store your password in the system keyring using the +The `icloud` command line interface is organized around service +subcommands such as `account`, `devices`, `calendar`, `contacts`, +`drive`, `photos`, `hidemyemail`, `reminders`, and `notes`. + +Global options such as `--username`, `--password`, `--session-dir`, +`--accept-terms`, `--with-family`, `--log-level`, and `--format` apply +before the service subcommand: + +```console +$ icloud --username=jappleseed@apple.com --format json account summary +``` + +You can store your password in the system keyring using the command-line tool: ```console -$ icloud --username=jappleseed@apple.com +$ icloud --username=jappleseed@apple.com account summary Enter iCloud password for jappleseed@apple.com: Save password in keyring? (y/N) ``` @@ -70,6 +82,23 @@ to provide a password when interacting with the command-line tool or instantiating the `PyiCloudService` class for the username you stored the password for. +Examples: + +```console +$ icloud --username=jappleseed@apple.com account summary +$ icloud --username=jappleseed@apple.com devices list --locate +$ icloud --username=jappleseed@apple.com devices show "Jacob's iPhone" +$ icloud --username=jappleseed@apple.com devices export "Jacob's iPhone" --output ./iphone.json +$ icloud --username=jappleseed@apple.com calendar events --period week +$ icloud --username=jappleseed@apple.com contacts me +$ icloud --username=jappleseed@apple.com drive list /Documents +$ icloud --username=jappleseed@apple.com photos albums +$ icloud --username=jappleseed@apple.com hidemyemail list +$ icloud --username=jappleseed@apple.com reminders lists +$ icloud --username=jappleseed@apple.com notes recent --limit 5 +$ icloud --username=jappleseed@apple.com --format json account summary +``` + ```python api = PyiCloudService('jappleseed@apple.com') ``` @@ -80,10 +109,17 @@ command-line option: ```console $ icloud --username=jappleseed@apple.com --delete-from-keyring -Enter iCloud password for jappleseed@apple.com: -Save password in keyring? [y/N]: N ``` +Migration notes for the previous Find My-focused CLI: + +- `--list` now maps to `icloud devices list` +- `--llist` now maps to `icloud devices show DEVICE --raw` +- `--outputfile` now maps to `icloud devices export DEVICE --output PATH` +- device action flags now map to explicit commands such as + `icloud devices sound DEVICE`, `icloud devices message DEVICE ...`, and + `icloud devices lost-mode DEVICE ...` + **Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. diff --git a/pyicloud/cli/__init__.py b/pyicloud/cli/__init__.py new file mode 100644 index 00000000..b4af3518 --- /dev/null +++ b/pyicloud/cli/__init__.py @@ -0,0 +1,5 @@ +"""Typer-based CLI entrypoints for pyicloud.""" + +from pyicloud.cli.app import app, main + +__all__ = ["app", "main"] diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py new file mode 100644 index 00000000..d19427b8 --- /dev/null +++ b/pyicloud/cli/app.py @@ -0,0 +1,139 @@ +"""Typer-based command line interface for pyicloud.""" + +from __future__ import annotations + +import typer + +from pyicloud.cli.commands.account import app as account_app +from pyicloud.cli.commands.calendar import app as calendar_app +from pyicloud.cli.commands.contacts import app as contacts_app +from pyicloud.cli.commands.devices import app as devices_app +from pyicloud.cli.commands.drive import app as drive_app +from pyicloud.cli.commands.hidemyemail import app as hidemyemail_app +from pyicloud.cli.commands.notes import app as notes_app +from pyicloud.cli.commands.photos import app as photos_app +from pyicloud.cli.commands.reminders import app as reminders_app +from pyicloud.cli.context import CLIAbort, CLIState, LogLevel +from pyicloud.cli.output import OutputFormat + +app = typer.Typer( + help="Command line interface for pyicloud services.", + no_args_is_help=True, + pretty_exceptions_show_locals=False, +) + +app.add_typer(account_app, name="account") +app.add_typer(devices_app, name="devices") +app.add_typer(calendar_app, name="calendar") +app.add_typer(contacts_app, name="contacts") +app.add_typer(drive_app, name="drive") +app.add_typer(photos_app, name="photos") +app.add_typer(hidemyemail_app, name="hidemyemail") +app.add_typer(reminders_app, name="reminders") +app.add_typer(notes_app, name="notes") + + +@app.callback(invoke_without_command=True) +def root( + ctx: typer.Context, + username: str = typer.Option("", "--username", help="Apple ID username."), + password: str | None = typer.Option( + None, + "--password", + help="Apple ID password. If omitted, pyicloud will use the system keyring or prompt interactively.", + ), + china_mainland: bool = typer.Option( + False, + "--china-mainland", + help="Use China mainland Apple web service endpoints.", + ), + interactive: bool = typer.Option( + True, + "--interactive/--non-interactive", + help="Enable or disable interactive prompts.", + ), + delete_from_keyring: bool = typer.Option( + False, + "--delete-from-keyring", + help="Delete the stored password for --username and exit if no command is given.", + ), + accept_terms: bool = typer.Option( + False, + "--accept-terms", + help="Automatically accept pending Apple iCloud web terms.", + ), + with_family: bool = typer.Option( + False, + "--with-family", + help="Include family devices in Find My device listings.", + ), + session_dir: str | None = typer.Option( + None, + "--session-dir", + help="Directory to store session and cookie files.", + ), + http_proxy: str | None = typer.Option(None, "--http-proxy"), + https_proxy: str | None = typer.Option(None, "--https-proxy"), + no_verify_ssl: bool = typer.Option( + False, + "--no-verify-ssl", + help="Disable SSL verification for requests.", + ), + log_level: LogLevel = typer.Option( + LogLevel.WARNING, + "--log-level", + case_sensitive=False, + help="Logging level for pyicloud internals.", + ), + output_format: OutputFormat = typer.Option( + OutputFormat.TEXT, + "--format", + case_sensitive=False, + help="Output format for command results.", + ), +) -> None: + """Initialize shared CLI state.""" + + state = CLIState( + username=username, + password=password, + china_mainland=china_mainland, + interactive=interactive, + delete_from_keyring=delete_from_keyring, + accept_terms=accept_terms, + with_family=with_family, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + log_level=log_level, + output_format=output_format, + ) + state.open() + ctx.call_on_close(state.close) + ctx.obj = state + + if delete_from_keyring: + deleted = state.delete_stored_password() + if ctx.invoked_subcommand is None: + state.console.print( + "Deleted stored password from keyring." + if deleted + else "No stored password was found for that username." + ) + raise typer.Exit() + + if ctx.invoked_subcommand is None: + state.console.print(ctx.get_help()) + raise typer.Exit() + + +def main() -> int: + """Run the Typer application.""" + + try: + app() + except CLIAbort as err: + typer.echo(str(err), err=True) + return 1 + return 0 diff --git a/pyicloud/cli/commands/__init__.py b/pyicloud/cli/commands/__init__.py new file mode 100644 index 00000000..bea730c1 --- /dev/null +++ b/pyicloud/cli/commands/__init__.py @@ -0,0 +1 @@ +"""Command groups for the Typer CLI.""" diff --git a/pyicloud/cli/commands/account.py b/pyicloud/cli/commands/account.py new file mode 100644 index 00000000..acb746c9 --- /dev/null +++ b/pyicloud/cli/commands/account.py @@ -0,0 +1,122 @@ +"""Account commands.""" + +from __future__ import annotations + +import typer + +from pyicloud.cli.context import get_state, service_call +from pyicloud.cli.normalize import ( + normalize_account_device, + normalize_account_summary, + normalize_family_member, + normalize_storage, +) +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Inspect iCloud account metadata.") + + +@app.command("summary") +def account_summary(ctx: typer.Context) -> None: + """Show high-level account information.""" + + state = get_state(ctx) + api = state.get_api() + account = service_call("Account", lambda: api.account) + payload = normalize_account_summary(api, account) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Account: {payload['account_name']}") + state.console.print(f"Devices: {payload['devices_count']}") + state.console.print(f"Family members: {payload['family_count']}") + state.console.print( + f"Storage: {payload['used_storage_percent']}% used " + f"({payload['used_storage_bytes']} / {payload['total_storage_bytes']} bytes)" + ) + + +@app.command("devices") +def account_devices(ctx: typer.Context) -> None: + """List devices associated with the account profile.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_account_device(device) + for device in service_call("Account", lambda: api.account.devices) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Account Devices", + ["Name", "Model", "Device Class", "ID"], + [ + ( + device["name"], + device["model_display_name"], + device["device_class"], + device["id"], + ) + for device in payload + ], + ) + ) + + +@app.command("family") +def account_family(ctx: typer.Context) -> None: + """List family sharing members.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_family_member(member) + for member in service_call("Account", lambda: api.account.family) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Family Members", + ["Name", "Apple ID", "Age", "Parent"], + [ + ( + member["full_name"], + member["apple_id"], + member["age_classification"], + member["has_parental_privileges"], + ) + for member in payload + ], + ) + ) + + +@app.command("storage") +def account_storage(ctx: typer.Context) -> None: + """Show iCloud storage usage.""" + + state = get_state(ctx) + api = state.get_api() + payload = normalize_storage(service_call("Account", lambda: api.account.storage)) + if state.json_output: + state.write_json(payload) + return + state.console.print( + f"Used {payload['usage']['used_storage_in_percent']}% " + f"of {payload['usage']['total_storage_in_bytes']} bytes." + ) + state.console.print( + console_table( + "Storage Usage", + ["Media", "Usage (bytes)", "Label"], + [ + (key, usage["usage_in_bytes"], usage["label"]) + for key, usage in payload["usages_by_media"].items() + ], + ) + ) diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py new file mode 100644 index 00000000..cd1e0a5f --- /dev/null +++ b/pyicloud/cli/commands/calendar.py @@ -0,0 +1,96 @@ +"""Calendar commands.""" + +from __future__ import annotations + +from itertools import islice +from typing import Optional + +import typer + +from pyicloud.cli.context import get_state, parse_datetime, service_call +from pyicloud.cli.normalize import normalize_calendar, normalize_event +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Inspect calendars and events.") + + +@app.command("calendars") +def calendar_calendars(ctx: typer.Context) -> None: + """List available calendars.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_calendar(calendar) + for calendar in service_call("Calendar", lambda: api.calendar.get_calendars()) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Calendars", + ["GUID", "Title", "Color", "Share Type"], + [ + ( + calendar["guid"], + calendar["title"], + calendar["color"], + calendar["share_type"], + ) + for calendar in payload + ], + ) + ) + + +@app.command("events") +def calendar_events( + ctx: typer.Context, + from_dt: Optional[str] = typer.Option(None, "--from", help="Start datetime."), + to_dt: Optional[str] = typer.Option(None, "--to", help="End datetime."), + period: str = typer.Option("month", "--period", help="Calendar period shortcut."), + calendar_guid: Optional[str] = typer.Option( + None, "--calendar-guid", help="Only show events from one calendar." + ), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum events to show."), +) -> None: + """List calendar events.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_event(event) + for event in service_call( + "Calendar", + lambda: api.calendar.get_events( + from_dt=parse_datetime(from_dt), + to_dt=parse_datetime(to_dt), + period=period, + ), + ) + ] + if calendar_guid: + payload = [ + event for event in payload if event["calendar_guid"] == calendar_guid + ] + payload = list(islice(payload, limit)) + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Events", + ["GUID", "Calendar", "Title", "Start", "End"], + [ + ( + event["guid"], + event["calendar_guid"], + event["title"], + event["start"], + event["end"], + ) + for event in payload + ], + ) + ) diff --git a/pyicloud/cli/commands/contacts.py b/pyicloud/cli/commands/contacts.py new file mode 100644 index 00000000..9d014a7f --- /dev/null +++ b/pyicloud/cli/commands/contacts.py @@ -0,0 +1,64 @@ +"""Contacts commands.""" + +from __future__ import annotations + +from itertools import islice + +import typer + +from pyicloud.cli.context import get_state, service_call +from pyicloud.cli.normalize import normalize_contact, normalize_me +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Inspect iCloud contacts.") + + +@app.command("list") +def contacts_list( + ctx: typer.Context, + limit: int = typer.Option(50, "--limit", min=1, help="Maximum contacts to show."), +) -> None: + """List contacts.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_contact(contact) + for contact in islice( + service_call("Contacts", lambda: api.contacts.all) or [], + limit, + ) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Contacts", + ["First", "Last", "Phones", "Emails"], + [ + ( + contact["first_name"], + contact["last_name"], + ", ".join(contact["phones"]), + ", ".join(contact["emails"]), + ) + for contact in payload + ], + ) + ) + + +@app.command("me") +def contacts_me(ctx: typer.Context) -> None: + """Show the signed-in contact card.""" + + state = get_state(ctx) + api = state.get_api() + payload = normalize_me(service_call("Contacts", lambda: api.contacts.me)) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"{payload['first_name']} {payload['last_name']}") + if payload["photo"]: + state.console.print(f"Photo URL: {payload['photo'].get('url')}") diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py new file mode 100644 index 00000000..fb657559 --- /dev/null +++ b/pyicloud/cli/commands/devices.py @@ -0,0 +1,216 @@ +"""Find My device commands.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from pyicloud.cli.context import get_state, resolve_device, service_call +from pyicloud.cli.normalize import normalize_device_details, normalize_device_summary +from pyicloud.cli.output import ( + console_kv_table, + console_table, + print_json_text, + write_json_file, +) + +app = typer.Typer(help="Work with Find My devices.") + + +@app.command("list") +def devices_list( + ctx: typer.Context, + locate: bool = typer.Option( + False, "--locate", help="Fetch current device locations." + ), +) -> None: + """List Find My devices.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_device_summary(device, locate=locate) + for device in service_call("Find My", lambda: api.devices) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Devices", + ["ID", "Name", "Display", "Model", "Battery", "Status"], + [ + ( + row["id"], + row["name"], + row["display_name"], + row["device_model"], + row["battery_level"], + row["battery_status"], + ) + for row in payload + ], + ) + ) + + +@app.command("show") +def devices_show( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + locate: bool = typer.Option( + False, "--locate", help="Fetch current device location." + ), + raw: bool = typer.Option(False, "--raw", help="Show the raw device payload."), +) -> None: + """Show detailed information for one device.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + payload = idevice.data if raw else normalize_device_details(idevice, locate=locate) + if state.json_output: + state.write_json(payload) + return + if raw: + print_json_text(state.console, payload) + return + state.console.print( + console_kv_table( + f"Device: {payload['name']}", + [ + ("ID", payload["id"]), + ("Display Name", payload["display_name"]), + ("Device Class", payload["device_class"]), + ("Device Model", payload["device_model"]), + ("Battery Level", payload["battery_level"]), + ("Battery Status", payload["battery_status"]), + ("Location", payload["location"]), + ], + ) + ) + + +@app.command("sound") +def devices_sound( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + subject: str = typer.Option("Find My iPhone Alert", "--subject"), +) -> None: + """Play a sound on a device.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + idevice.play_sound(subject=subject) + payload = {"device_id": idevice.id, "subject": subject} + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Requested sound alert for {idevice.name}.") + + +@app.command("message") +def devices_message( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + message: str = typer.Argument(..., help="Message to display."), + subject: str = typer.Option("A Message", "--subject"), + silent: bool = typer.Option(False, "--silent", help="Do not play a sound."), +) -> None: + """Display a message on a device.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + idevice.display_message(subject=subject, message=message, sounds=not silent) + payload = { + "device_id": idevice.id, + "subject": subject, + "message": message, + "silent": silent, + } + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Requested message for {idevice.name}.") + + +@app.command("lost-mode") +def devices_lost_mode( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + phone: str = typer.Option("", "--phone", help="Phone number shown in lost mode."), + message: str = typer.Option( + "This iPhone has been lost. Please call me.", + "--message", + help="Lost mode message.", + ), + passcode: str = typer.Option("", "--passcode", help="New device passcode."), +) -> None: + """Enable lost mode for a device.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + idevice.lost_device(number=phone, text=message, newpasscode=passcode) + payload = { + "device_id": idevice.id, + "phone": phone, + "message": message, + "passcode": passcode, + } + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Requested lost mode for {idevice.name}.") + + +@app.command("erase") +def devices_erase( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + message: str = typer.Option( + "This iPhone has been lost. Please call me.", + "--message", + ), +) -> None: + """Request a remote erase for a device.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + idevice.erase_device(message) + payload = {"device_id": idevice.id, "message": message} + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Requested remote erase for {idevice.name}.") + + +@app.command("export") +def devices_export( + ctx: typer.Context, + device: str = typer.Argument(..., help="Device id or name."), + output: Path = typer.Option(..., "--output", help="Destination JSON file."), + raw: bool = typer.Option( + True, + "--raw/--normalized", + help="Write the raw device payload instead of normalized fields.", + ), + locate: bool = typer.Option( + False, "--locate", help="Include the current location in normalized exports." + ), +) -> None: + """Export a device snapshot to JSON.""" + + state = get_state(ctx) + api = state.get_api() + idevice = resolve_device(api, device) + payload = idevice.data if raw else normalize_device_details(idevice, locate=locate) + write_json_file(output, payload) + if state.json_output: + state.write_json({"device_id": idevice.id, "path": str(output), "raw": raw}) + return + state.console.print(str(output)) diff --git a/pyicloud/cli/commands/drive.py b/pyicloud/cli/commands/drive.py new file mode 100644 index 00000000..0c7a73c2 --- /dev/null +++ b/pyicloud/cli/commands/drive.py @@ -0,0 +1,95 @@ +"""Drive commands.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from pyicloud.cli.context import ( + CLIAbort, + get_state, + resolve_drive_node, + service_call, + write_response_to_path, +) +from pyicloud.cli.normalize import normalize_drive_node +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Browse and download iCloud Drive files.") + + +@app.command("list") +def drive_list( + ctx: typer.Context, + path: str = typer.Argument("/", help="Drive path, for example /Documents."), + trash: bool = typer.Option( + False, "--trash", help="Resolve the path from the trash root." + ), +) -> None: + """List a drive folder or inspect a file.""" + + state = get_state(ctx) + api = state.get_api() + drive = service_call("Drive", lambda: api.drive) + node = resolve_drive_node(drive, path, trash=trash) + if node.type == "file": + payload = normalize_drive_node(node) + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Drive Item", + ["Name", "Type", "Size", "Modified"], + [ + ( + payload["name"], + payload["type"], + payload["size"], + payload["modified"], + ) + ], + ) + ) + return + + payload = [normalize_drive_node(child) for child in node.get_children()] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + f"Drive: {path}", + ["Name", "Type", "Size", "Modified"], + [ + (item["name"], item["type"], item["size"], item["modified"]) + for item in payload + ], + ) + ) + + +@app.command("download") +def drive_download( + ctx: typer.Context, + path: str = typer.Argument(..., help="Drive path to the file."), + output: Path = typer.Option(..., "--output", help="Destination file path."), + trash: bool = typer.Option( + False, "--trash", help="Resolve the path from the trash root." + ), +) -> None: + """Download a Drive file.""" + + state = get_state(ctx) + api = state.get_api() + drive = service_call("Drive", lambda: api.drive) + node = resolve_drive_node(drive, path, trash=trash) + if node.type != "file": + raise CLIAbort("Only files can be downloaded.") + response = node.open(stream=True) + write_response_to_path(response, output) + if state.json_output: + state.write_json({"path": str(output), "name": node.name}) + return + state.console.print(str(output)) diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py new file mode 100644 index 00000000..eb52b05e --- /dev/null +++ b/pyicloud/cli/commands/hidemyemail.py @@ -0,0 +1,143 @@ +"""Hide My Email commands.""" + +from __future__ import annotations + +import typer + +from pyicloud.cli.context import get_state, service_call +from pyicloud.cli.normalize import normalize_alias +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Manage Hide My Email aliases.") + + +@app.command("list") +def hidemyemail_list(ctx: typer.Context) -> None: + """List Hide My Email aliases.""" + + state = get_state(ctx) + api = state.get_api() + payload = [ + normalize_alias(alias) + for alias in service_call("Hide My Email", lambda: api.hidemyemail) + ] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Hide My Email", + ["Alias", "Label", "Anonymous ID"], + [ + (alias["email"], alias["label"], alias["anonymous_id"]) + for alias in payload + ], + ) + ) + + +@app.command("generate") +def hidemyemail_generate(ctx: typer.Context) -> None: + """Generate a new relay address.""" + + state = get_state(ctx) + api = state.get_api() + alias = service_call("Hide My Email", lambda: api.hidemyemail.generate()) + payload = {"email": alias} + if state.json_output: + state.write_json(payload) + return + state.console.print(alias or "") + + +@app.command("reserve") +def hidemyemail_reserve( + ctx: typer.Context, + email: str = typer.Argument(...), + label: str = typer.Argument(...), + note: str = typer.Option("Generated", "--note", help="Alias note."), +) -> None: + """Reserve a generated relay address with metadata.""" + + state = get_state(ctx) + api = state.get_api() + payload = service_call( + "Hide My Email", + lambda: api.hidemyemail.reserve(email=email, label=label, note=note), + ) + if state.json_output: + state.write_json(payload) + return + state.console.print(payload.get("anonymousId", "reserved")) + + +@app.command("update") +def hidemyemail_update( + ctx: typer.Context, + anonymous_id: str = typer.Argument(...), + label: str = typer.Argument(...), + note: str = typer.Option("Generated", "--note", help="Alias note."), +) -> None: + """Update alias metadata.""" + + state = get_state(ctx) + api = state.get_api() + payload = service_call( + "Hide My Email", + lambda: api.hidemyemail.update_metadata(anonymous_id, label, note), + ) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Updated {anonymous_id}") + + +@app.command("deactivate") +def hidemyemail_deactivate( + ctx: typer.Context, anonymous_id: str = typer.Argument(...) +) -> None: + """Deactivate an alias.""" + + state = get_state(ctx) + api = state.get_api() + payload = service_call( + "Hide My Email", lambda: api.hidemyemail.deactivate(anonymous_id) + ) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Deactivated {anonymous_id}") + + +@app.command("reactivate") +def hidemyemail_reactivate( + ctx: typer.Context, anonymous_id: str = typer.Argument(...) +) -> None: + """Reactivate an alias.""" + + state = get_state(ctx) + api = state.get_api() + payload = service_call( + "Hide My Email", lambda: api.hidemyemail.reactivate(anonymous_id) + ) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Reactivated {anonymous_id}") + + +@app.command("delete") +def hidemyemail_delete( + ctx: typer.Context, anonymous_id: str = typer.Argument(...) +) -> None: + """Delete an alias.""" + + state = get_state(ctx) + api = state.get_api() + payload = service_call( + "Hide My Email", lambda: api.hidemyemail.delete(anonymous_id) + ) + if state.json_output: + state.write_json(payload) + return + state.console.print(f"Deleted {anonymous_id}") diff --git a/pyicloud/cli/commands/notes.py b/pyicloud/cli/commands/notes.py new file mode 100644 index 00000000..34e93d57 --- /dev/null +++ b/pyicloud/cli/commands/notes.py @@ -0,0 +1,225 @@ +"""Notes commands.""" + +from __future__ import annotations + +from itertools import islice +from pathlib import Path +from typing import Optional + +import typer + +from pyicloud.cli.context import get_state, service_call +from pyicloud.cli.normalize import select_recent_notes +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Inspect, render, and export Notes.") + + +@app.command("recent") +def notes_recent( + ctx: typer.Context, + limit: int = typer.Option(10, "--limit", min=1, help="Maximum notes to show."), + include_deleted: bool = typer.Option( + False, + "--include-deleted", + help="Include notes from Recently Deleted.", + ), +) -> None: + """List recent notes.""" + + state = get_state(ctx) + api = state.get_api() + rows = service_call( + "Notes", + lambda: select_recent_notes(api, limit=limit, include_deleted=include_deleted), + ) + if state.json_output: + state.write_json(rows) + return + state.console.print( + console_table( + "Recent Notes", + ["ID", "Title", "Folder", "Modified"], + [(row.id, row.title, row.folder_name, row.modified_at) for row in rows], + ) + ) + + +@app.command("folders") +def notes_folders(ctx: typer.Context) -> None: + """List note folders.""" + + state = get_state(ctx) + api = state.get_api() + rows = list(service_call("Notes", lambda: api.notes.folders())) + if state.json_output: + state.write_json(rows) + return + state.console.print( + console_table( + "Note Folders", + ["ID", "Name", "Parent", "Has Subfolders"], + [(row.id, row.name, row.parent_id, row.has_subfolders) for row in rows], + ) + ) + + +@app.command("list") +def notes_list( + ctx: typer.Context, + folder_id: Optional[str] = typer.Option(None, "--folder-id", help="Folder id."), + all_notes: bool = typer.Option(False, "--all", help="Iterate all notes."), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum notes to show."), + since: Optional[str] = typer.Option( + None, "--since", help="Incremental sync cursor for iter_all()." + ), +) -> None: + """List notes.""" + + state = get_state(ctx) + api = state.get_api() + if folder_id: + rows = list( + service_call("Notes", lambda: api.notes.in_folder(folder_id, limit=limit)) + ) + elif all_notes: + rows = list( + islice( + service_call("Notes", lambda: api.notes.iter_all(since=since)), limit + ) + ) + else: + rows = list(service_call("Notes", lambda: api.notes.recents(limit=limit))) + if state.json_output: + state.write_json(rows) + return + state.console.print( + console_table( + "Notes", + ["ID", "Title", "Folder", "Modified"], + [(row.id, row.title, row.folder_name, row.modified_at) for row in rows], + ) + ) + + +@app.command("get") +def notes_get( + ctx: typer.Context, + note_id: str = typer.Argument(...), + with_attachments: bool = typer.Option(False, "--with-attachments"), +) -> None: + """Get one note.""" + + state = get_state(ctx) + api = state.get_api() + note = service_call( + "Notes", + lambda: api.notes.get(note_id, with_attachments=with_attachments), + ) + if state.json_output: + state.write_json(note) + return + state.console.print(f"{note.title} [{note.id}]") + if note.text: + state.console.print(note.text) + if with_attachments and note.attachments: + state.console.print( + console_table( + "Attachments", + ["ID", "Filename", "UTI", "Size"], + [(att.id, att.filename, att.uti, att.size) for att in note.attachments], + ) + ) + + +@app.command("render") +def notes_render( + ctx: typer.Context, + note_id: str = typer.Argument(...), + preview_appearance: str = typer.Option("light", "--preview-appearance"), + pdf_height: int = typer.Option(600, "--pdf-height"), +) -> None: + """Render a note to HTML.""" + + state = get_state(ctx) + api = state.get_api() + html = service_call( + "Notes", + lambda: api.notes.render_note( + note_id, + preview_appearance=preview_appearance, + pdf_object_height=pdf_height, + ), + ) + if state.json_output: + state.write_json({"note_id": note_id, "html": html}) + return + state.console.print(html, soft_wrap=True) + + +@app.command("export") +def notes_export( + ctx: typer.Context, + note_id: str = typer.Argument(...), + output_dir: Path = typer.Argument(...), + export_mode: str = typer.Option("archival", "--export-mode"), + assets_dir: Optional[Path] = typer.Option(None, "--assets-dir"), + full_page: bool = typer.Option(True, "--full-page/--fragment"), + preview_appearance: str = typer.Option("light", "--preview-appearance"), + pdf_height: int = typer.Option(600, "--pdf-height"), +) -> None: + """Export a note to disk.""" + + state = get_state(ctx) + api = state.get_api() + path = service_call( + "Notes", + lambda: api.notes.export_note( + note_id, + str(output_dir), + export_mode=export_mode, + assets_dir=str(assets_dir) if assets_dir else None, + full_page=full_page, + preview_appearance=preview_appearance, + pdf_object_height=pdf_height, + ), + ) + if state.json_output: + state.write_json({"note_id": note_id, "path": path}) + return + state.console.print(path) + + +@app.command("changes") +def notes_changes( + ctx: typer.Context, + since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum changes to show."), +) -> None: + """List note changes since a cursor.""" + + state = get_state(ctx) + api = state.get_api() + rows = list( + islice( + service_call("Notes", lambda: api.notes.iter_changes(since=since)), limit + ) + ) + if state.json_output: + state.write_json(rows) + return + state.console.print( + console_table( + "Note Changes", + ["Type", "Note ID", "Folder", "Modified"], + [ + ( + row.type, + row.note.id if row.note else row.note_id, + row.note.folder_name if row.note else None, + row.note.modified_at if row.note else None, + ) + for row in rows + ], + ) + ) diff --git a/pyicloud/cli/commands/photos.py b/pyicloud/cli/commands/photos.py new file mode 100644 index 00000000..bcb280d7 --- /dev/null +++ b/pyicloud/cli/commands/photos.py @@ -0,0 +1,102 @@ +"""Photos commands.""" + +from __future__ import annotations + +from itertools import islice +from pathlib import Path +from typing import Optional + +import typer + +from pyicloud.cli.context import CLIAbort, get_state, service_call +from pyicloud.cli.normalize import normalize_album, normalize_photo +from pyicloud.cli.output import console_table + +app = typer.Typer(help="Browse and download iCloud Photos.") + + +@app.command("albums") +def photos_albums(ctx: typer.Context) -> None: + """List photo albums.""" + + state = get_state(ctx) + api = state.get_api() + photos = service_call("Photos", lambda: api.photos) + payload = [normalize_album(album) for album in photos.albums] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Photo Albums", + ["Name", "Full Name", "Count"], + [(album["name"], album["full_name"], album["count"]) for album in payload], + ) + ) + + +@app.command("list") +def photos_list( + ctx: typer.Context, + album: Optional[str] = typer.Option( + None, "--album", help="Album name. Defaults to all photos." + ), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum photos to show."), +) -> None: + """List photo assets.""" + + state = get_state(ctx) + api = state.get_api() + photos = service_call("Photos", lambda: api.photos) + album_obj = photos.albums.find(album) if album else photos.all + if album and album_obj is None: + raise CLIAbort(f"No album named '{album}' was found.") + photos_iter = album_obj.photos if album_obj is not None else photos.all.photos + payload = [normalize_photo(item) for item in islice(photos_iter, limit)] + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_table( + "Photos", + ["ID", "Filename", "Type", "Created", "Size"], + [ + ( + photo["id"], + photo["filename"], + photo["item_type"], + photo["created"], + photo["size"], + ) + for photo in payload + ], + ) + ) + + +@app.command("download") +def photos_download( + ctx: typer.Context, + photo_id: str = typer.Argument(..., help="Photo asset id."), + output: Path = typer.Option(..., "--output", help="Destination file path."), + version: str = typer.Option( + "original", "--version", help="Photo version to download." + ), +) -> None: + """Download a photo asset.""" + + state = get_state(ctx) + api = state.get_api() + photos = service_call("Photos", lambda: api.photos) + photo = photos.all[photo_id] + data = photo.download(version=version) + if data is None: + raise CLIAbort("No data was returned for that photo version.") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(data) + if state.json_output: + state.write_json( + {"photo_id": photo_id, "path": str(output), "version": version} + ) + return + state.console.print(str(output)) diff --git a/pyicloud/cli/commands/reminders.py b/pyicloud/cli/commands/reminders.py new file mode 100644 index 00000000..037f4aab --- /dev/null +++ b/pyicloud/cli/commands/reminders.py @@ -0,0 +1,197 @@ +"""Reminders commands.""" + +from __future__ import annotations + +from itertools import islice +from typing import Optional + +import typer + +from pyicloud.cli.context import get_state, parse_datetime, service_call +from pyicloud.cli.output import console_table, format_color_value + +app = typer.Typer(help="Inspect and mutate Reminders.") + + +@app.command("lists") +def reminders_lists(ctx: typer.Context) -> None: + """List reminder lists.""" + + state = get_state(ctx) + api = state.get_api() + rows = list(service_call("Reminders", lambda: api.reminders.lists())) + if state.json_output: + state.write_json(rows) + return + state.console.print( + console_table( + "Reminder Lists", + ["ID", "Title", "Color", "Count"], + [ + (row.id, row.title, format_color_value(row.color), row.count) + for row in rows + ], + ) + ) + + +@app.command("list") +def reminders_list( + ctx: typer.Context, + list_id: Optional[str] = typer.Option(None, "--list-id", help="List id."), + include_completed: bool = typer.Option( + False, "--include-completed", help="Include completed reminders." + ), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum reminders to show."), +) -> None: + """List reminders.""" + + state = get_state(ctx) + api = state.get_api() + reminders = list( + islice( + service_call( + "Reminders", + lambda: api.reminders.reminders( + list_id=list_id, + include_completed=include_completed, + ), + ), + limit, + ) + ) + if state.json_output: + state.write_json(reminders) + return + state.console.print( + console_table( + "Reminders", + ["ID", "Title", "Completed", "Due", "Priority"], + [ + ( + reminder.id, + reminder.title, + reminder.completed, + reminder.due_date, + reminder.priority, + ) + for reminder in reminders + ], + ) + ) + + +@app.command("get") +def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> None: + """Get one reminder.""" + + state = get_state(ctx) + api = state.get_api() + reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) + if state.json_output: + state.write_json(reminder) + return + state.console.print(f"{reminder.title} [{reminder.id}]") + if reminder.desc: + state.console.print(reminder.desc) + if reminder.due_date: + state.console.print(f"Due: {reminder.due_date}") + + +@app.command("create") +def reminders_create( + ctx: typer.Context, + list_id: str = typer.Option(..., "--list-id", help="Target list id."), + title: str = typer.Option(..., "--title", help="Reminder title."), + desc: str = typer.Option("", "--desc", help="Reminder description."), + due_date: Optional[str] = typer.Option(None, "--due-date", help="Due datetime."), + priority: int = typer.Option(0, "--priority", help="Apple priority number."), + flagged: bool = typer.Option(False, "--flagged", help="Flag the reminder."), + all_day: bool = typer.Option(False, "--all-day", help="Mark as all-day."), +) -> None: + """Create a reminder.""" + + state = get_state(ctx) + api = state.get_api() + reminder = service_call( + "Reminders", + lambda: api.reminders.create( + list_id=list_id, + title=title, + desc=desc, + due_date=parse_datetime(due_date), + priority=priority, + flagged=flagged, + all_day=all_day, + ), + ) + if state.json_output: + state.write_json(reminder) + return + state.console.print(reminder.id) + + +@app.command("set-status") +def reminders_set_status( + ctx: typer.Context, + reminder_id: str = typer.Argument(...), + completed: bool = typer.Option(True, "--completed/--not-completed"), +) -> None: + """Mark a reminder completed or incomplete.""" + + state = get_state(ctx) + api = state.get_api() + reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) + reminder.completed = completed + service_call("Reminders", lambda: api.reminders.update(reminder)) + if state.json_output: + state.write_json(reminder) + return + state.console.print(f"Updated {reminder.id}: completed={completed}") + + +@app.command("delete") +def reminders_delete( + ctx: typer.Context, reminder_id: str = typer.Argument(...) +) -> None: + """Delete a reminder.""" + + state = get_state(ctx) + api = state.get_api() + reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) + service_call("Reminders", lambda: api.reminders.delete(reminder)) + if state.json_output: + state.write_json({"reminder_id": reminder.id, "deleted": True}) + return + state.console.print(f"Deleted {reminder.id}") + + +@app.command("changes") +def reminders_changes( + ctx: typer.Context, + since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), + limit: int = typer.Option(50, "--limit", min=1, help="Maximum changes to show."), +) -> None: + """List reminder changes since a cursor.""" + + state = get_state(ctx) + api = state.get_api() + events = list( + islice( + service_call("Reminders", lambda: api.reminders.iter_changes(since=since)), + limit, + ) + ) + if state.json_output: + state.write_json(events) + return + state.console.print( + console_table( + "Reminder Changes", + ["Type", "Reminder ID", "Has Reminder"], + [ + (event.type, event.reminder_id, event.reminder is not None) + for event in events + ], + ) + ) diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py new file mode 100644 index 00000000..f64dd964 --- /dev/null +++ b/pyicloud/cli/context.py @@ -0,0 +1,315 @@ +"""Shared context and authentication helpers for the Typer CLI.""" + +from __future__ import annotations + +import logging +from contextlib import ExitStack +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path, PurePosixPath +from typing import Any, Optional + +import typer +from click import confirm +from rich.console import Console + +from pyicloud import PyiCloudService, utils +from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable +from pyicloud.ssl_context import configurable_ssl_verification + +from .output import OutputFormat, write_json + + +class CLIAbort(RuntimeError): + """Abort execution with a user-facing message.""" + + +class LogLevel(str, Enum): + """Supported log levels.""" + + ERROR = "error" + WARNING = "warning" + INFO = "info" + DEBUG = "debug" + + def logging_level(self) -> int: + """Return the stdlib logging level.""" + + if self is LogLevel.ERROR: + return logging.ERROR + if self is LogLevel.INFO: + return logging.INFO + if self is LogLevel.DEBUG: + return logging.DEBUG + return logging.WARNING + + +class CLIState: + """Shared CLI state and authenticated API access.""" + + def __init__( + self, + *, + username: str, + password: Optional[str], + china_mainland: bool, + interactive: bool, + delete_from_keyring: bool, + accept_terms: bool, + with_family: bool, + session_dir: Optional[str], + http_proxy: Optional[str], + https_proxy: Optional[str], + no_verify_ssl: bool, + log_level: LogLevel, + output_format: OutputFormat, + ) -> None: + self.username = username.strip() + self.password = password + self.china_mainland = china_mainland + self.interactive = interactive + self.delete_from_keyring = delete_from_keyring + self.accept_terms = accept_terms + self.with_family = with_family + self.session_dir = session_dir + self.http_proxy = http_proxy + self.https_proxy = https_proxy + self.no_verify_ssl = no_verify_ssl + self.log_level = log_level + self.output_format = output_format + self.console = Console() + self.err_console = Console(stderr=True) + self._stack = ExitStack() + self._api: Optional[PyiCloudService] = None + + @property + def json_output(self) -> bool: + """Return whether the current command expects JSON.""" + + return self.output_format is OutputFormat.JSON + + def open(self) -> None: + """Open CLI-scoped resources.""" + + self._stack.enter_context( + configurable_ssl_verification( + verify_ssl=not self.no_verify_ssl, + http_proxy=self.http_proxy or "", + https_proxy=self.https_proxy or "", + ) + ) + + def close(self) -> None: + """Close CLI-scoped resources.""" + + self._stack.close() + + def write_json(self, payload: Any) -> None: + """Write a JSON payload to stdout.""" + + write_json(self.console, payload) + + def delete_stored_password(self) -> bool: + """Delete a stored keyring password.""" + + if not self.username: + raise CLIAbort("A username is required with --delete-from-keyring.") + if utils.password_exists_in_keyring(self.username): + utils.delete_password_in_keyring(self.username) + return True + return False + + def _password_for_login(self) -> Optional[str]: + if self.password: + return self.password + return utils.get_password(self.username, interactive=self.interactive) + + def _prompt_index(self, prompt: str, count: int) -> int: + if count <= 1 or not self.interactive: + return 0 + raw = typer.prompt(prompt, default="0") + try: + idx = int(raw) + except ValueError as exc: + raise CLIAbort("Invalid device selection.") from exc + if idx < 0 or idx >= count: + raise CLIAbort("Invalid device selection.") + return idx + + def _handle_2fa(self, api: PyiCloudService) -> None: + fido2_devices = list(getattr(api, "fido2_devices", []) or []) + if fido2_devices: + self.console.print("Security key verification required.") + for index, _device in enumerate(fido2_devices): + self.console.print(f" {index}: Security key {index}") + selected_index = self._prompt_index( + "Select security key index", len(fido2_devices) + ) + self.console.print("Touch the selected security key to continue.") + try: + api.confirm_security_key(fido2_devices[selected_index]) + except Exception as exc: # pragma: no cover - live auth path + raise CLIAbort("Security key verification failed.") from exc + else: + if not self.interactive: + raise CLIAbort( + "Two-factor authentication is required, but interactive prompts are disabled." + ) + code = typer.prompt("Enter 2FA code") + if not api.validate_2fa_code(code): + raise CLIAbort("Failed to verify the 2FA code.") + if not api.is_trusted_session: + api.trust_session() + + def _handle_2sa(self, api: PyiCloudService) -> None: + devices = list(api.trusted_devices or []) + if not devices: + raise CLIAbort( + "Two-step authentication is required but no trusted devices are available." + ) + self.console.print("Trusted devices:") + for index, device in enumerate(devices): + label = ( + "SMS trusted device" if device.get("phoneNumber") else "Trusted device" + ) + self.console.print(f" {index}: {label}") + selected_index = self._prompt_index("Select trusted device index", len(devices)) + device = devices[selected_index] + if not api.send_verification_code(device): + raise CLIAbort("Failed to send the 2SA verification code.") + if not self.interactive: + raise CLIAbort( + "Two-step authentication is required, but interactive prompts are disabled." + ) + code = typer.prompt("Enter 2SA verification code") + if not api.validate_verification_code(device, code): + raise CLIAbort("Failed to verify the 2SA code.") + + def get_api(self) -> PyiCloudService: + """Return an authenticated PyiCloudService instance.""" + + if self._api is not None: + return self._api + if not self.username: + raise CLIAbort( + "The --username option is required for authenticated commands." + ) + + password = self._password_for_login() + if not password: + raise CLIAbort("No password supplied and no stored password was found.") + + logging.basicConfig(level=self.log_level.logging_level()) + + try: + api = PyiCloudService( + apple_id=self.username, + password=password, + china_mainland=self.china_mainland, + cookie_directory=self.session_dir, + accept_terms=self.accept_terms, + with_family=self.with_family, + ) + except PyiCloudFailedLoginException as err: + if utils.password_exists_in_keyring(self.username): + utils.delete_password_in_keyring(self.username) + raise CLIAbort(f"Bad username or password for {self.username}") from err + + if ( + not utils.password_exists_in_keyring(self.username) + and self.interactive + and confirm("Save password in keyring?") + ): + utils.store_password_in_keyring(self.username, password) + + if api.requires_2fa: + self._handle_2fa(api) + elif api.requires_2sa: + self._handle_2sa(api) + + self._api = api + return api + + +def get_state(ctx: typer.Context) -> CLIState: + """Return the shared CLI state for a command.""" + + state = ctx.obj + if not isinstance(state, CLIState): + raise RuntimeError("CLI state was not initialized.") + return state + + +def service_call(label: str, fn): + """Wrap a service call with user-facing service-unavailable handling.""" + + try: + return fn() + except PyiCloudServiceUnavailable as err: + raise CLIAbort(f"{label} service unavailable: {err}") from err + + +def parse_datetime(value: Optional[str]) -> Optional[datetime]: + """Parse an ISO-8601 datetime string.""" + + if value is None: + return None + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(normalized) + except ValueError as exc: + raise typer.BadParameter("Expected an ISO-8601 datetime value.") from exc + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def resolve_device(api: PyiCloudService, query: str): + """Return a device matched by id or common display names.""" + + lowered = query.strip().lower() + for device in api.devices: + candidates = [ + getattr(device, "id", ""), + getattr(device, "name", ""), + getattr(device, "deviceDisplayName", ""), + ] + if any(str(candidate).strip().lower() == lowered for candidate in candidates): + return device + raise CLIAbort(f"No device matched '{query}'.") + + +def resolve_drive_node(drive, path: str, *, trash: bool = False): + """Resolve an iCloud Drive node.""" + + node = drive.trash if trash else drive.root + normalized = PurePosixPath(path or "/") + if str(normalized) in {".", "/"}: + return node + for part in normalized.parts: + if part in {"", "/"}: + continue + node = node[part] + return node + + +def write_response_to_path(response: Any, output: Path) -> None: + """Stream a download response to disk.""" + + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as file_out: + if hasattr(response, "iter_content"): + for chunk in response.iter_content(chunk_size=8192): + if chunk: + file_out.write(chunk) + return + if hasattr(response, "raw") and hasattr(response.raw, "read"): + while True: + chunk = response.raw.read(8192) + if not chunk: + break + file_out.write(chunk) + return + raise CLIAbort("The download response could not be streamed.") diff --git a/pyicloud/cli/normalize.py b/pyicloud/cli/normalize.py new file mode 100644 index 00000000..acdd1c9d --- /dev/null +++ b/pyicloud/cli/normalize.py @@ -0,0 +1,196 @@ +"""Normalization helpers for CLI payloads.""" + +from __future__ import annotations + +from typing import Any + + +def normalize_account_summary(api, account) -> dict[str, Any]: + """Normalize account summary data.""" + + storage = account.storage + return { + "account_name": api.account_name, + "devices_count": len(account.devices), + "family_count": len(account.family), + "used_storage_bytes": storage.usage.used_storage_in_bytes, + "available_storage_bytes": storage.usage.available_storage_in_bytes, + "total_storage_bytes": storage.usage.total_storage_in_bytes, + "used_storage_percent": storage.usage.used_storage_in_percent, + "summary_plan": account.summary_plan, + } + + +def normalize_account_device(device: dict[str, Any]) -> dict[str, Any]: + """Normalize account device data.""" + + return { + "id": device.get("id"), + "name": device.get("name"), + "model_display_name": device.get("modelDisplayName"), + "device_class": device.get("deviceClass"), + } + + +def normalize_family_member(member: Any) -> dict[str, Any]: + """Normalize family member data.""" + + return { + "full_name": member.full_name, + "apple_id": member.apple_id, + "dsid": member.dsid, + "age_classification": member.age_classification, + "has_parental_privileges": member.has_parental_privileges, + } + + +def normalize_storage(storage: Any) -> dict[str, Any]: + """Normalize storage usage payloads.""" + + return { + "usage": { + "used_storage_in_bytes": storage.usage.used_storage_in_bytes, + "available_storage_in_bytes": storage.usage.available_storage_in_bytes, + "total_storage_in_bytes": storage.usage.total_storage_in_bytes, + "used_storage_in_percent": storage.usage.used_storage_in_percent, + }, + "usages_by_media": { + key: { + "label": usage.label, + "color": usage.color, + "usage_in_bytes": usage.usage_in_bytes, + } + for key, usage in storage.usages_by_media.items() + }, + } + + +def normalize_device_summary(device: Any, *, locate: bool) -> dict[str, Any]: + """Normalize a Find My device for summary views.""" + + return { + "id": getattr(device, "id", None), + "name": getattr(device, "name", None), + "display_name": getattr(device, "deviceDisplayName", None), + "device_class": getattr(device, "deviceClass", None), + "device_model": getattr(device, "deviceModel", None), + "battery_level": getattr(device, "batteryLevel", None), + "battery_status": getattr(device, "batteryStatus", None), + "location": getattr(device, "location", None) if locate else None, + } + + +def normalize_device_details(device: Any, *, locate: bool) -> dict[str, Any]: + """Normalize a Find My device for detailed views.""" + + payload = normalize_device_summary(device, locate=locate) + payload["raw_data"] = getattr(device, "data", None) + return payload + + +def normalize_calendar(calendar: dict[str, Any]) -> dict[str, Any]: + """Normalize a calendar entry.""" + + return { + "guid": calendar.get("guid"), + "title": calendar.get("title"), + "color": calendar.get("color"), + "share_type": calendar.get("shareType"), + } + + +def normalize_event(event: dict[str, Any]) -> dict[str, Any]: + """Normalize a calendar event.""" + + return { + "guid": event.get("guid"), + "calendar_guid": event.get("pGuid"), + "title": event.get("title"), + "start": event.get("startDate"), + "end": event.get("endDate"), + } + + +def normalize_contact(contact: dict[str, Any]) -> dict[str, Any]: + """Normalize a contact entry.""" + + return { + "first_name": contact.get("firstName"), + "last_name": contact.get("lastName"), + "phones": [phone.get("field", "") for phone in contact.get("phones", [])], + "emails": [email.get("field", "") for email in contact.get("emails", [])], + } + + +def normalize_me(me: Any) -> dict[str, Any]: + """Normalize the 'me' contact payload.""" + + return { + "first_name": me.first_name, + "last_name": me.last_name, + "photo": me.photo, + "raw_data": me.raw_data, + } + + +def normalize_drive_node(node: Any) -> dict[str, Any]: + """Normalize an iCloud Drive node.""" + + return { + "name": node.name, + "type": node.type, + "size": node.size, + "modified": node.date_modified, + } + + +def normalize_album(album: Any) -> dict[str, Any]: + """Normalize a photo album.""" + + return { + "name": album.name, + "full_name": album.fullname, + "count": len(album), + } + + +def normalize_photo(item: Any) -> dict[str, Any]: + """Normalize a photo asset.""" + + return { + "id": item.id, + "filename": item.filename, + "item_type": item.item_type, + "created": item.created, + "size": item.size, + } + + +def normalize_alias(alias: dict[str, Any]) -> dict[str, Any]: + """Normalize a Hide My Email alias.""" + + return { + "email": alias.get("hme"), + "label": alias.get("label"), + "anonymous_id": alias.get("anonymousId"), + } + + +def select_recent_notes(api: Any, *, limit: int, include_deleted: bool) -> list[Any]: + """Return recent notes, excluding deleted notes by default.""" + + if include_deleted: + return list(api.notes.recents(limit=limit)) + + probe_limit = limit + max_probe = min(max(limit, 10) * 8, 500) + while True: + rows = list(api.notes.recents(limit=probe_limit)) + filtered = [row for row in rows if not getattr(row, "is_deleted", False)] + if ( + len(filtered) >= limit + or len(rows) < probe_limit + or probe_limit >= max_probe + ): + return filtered[:limit] + probe_limit = min(probe_limit * 2, max_probe) diff --git a/pyicloud/cli/output.py b/pyicloud/cli/output.py new file mode 100644 index 00000000..3a85ea8a --- /dev/null +++ b/pyicloud/cli/output.py @@ -0,0 +1,137 @@ +"""Shared output helpers for the Typer CLI.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, is_dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Iterable + +from rich.console import Console +from rich.table import Table + + +class OutputFormat(str, Enum): + """Supported output formats.""" + + TEXT = "text" + JSON = "json" + + +def json_default(value: Any) -> Any: + """Serialize common CLI values to JSON-friendly structures.""" + + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Path): + return str(value) + if is_dataclass(value): + return asdict(value) + if isinstance(value, SimpleNamespace): + return vars(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump(mode="json") + except TypeError: + return value.model_dump() + if hasattr(value, "raw_data"): + return value.raw_data + if hasattr(value, "data") and isinstance(value.data, dict): + return value.data + if isinstance(value, set): + return sorted(value) + if isinstance(value, bytes): + return value.decode(errors="replace") + return str(value) + + +def to_json_string(payload: Any, *, indent: int | None = None) -> str: + """Render a payload as JSON.""" + + return json.dumps( + payload, + default=json_default, + ensure_ascii=False, + indent=indent, + sort_keys=indent is not None, + ) + + +def write_json(console: Console, payload: Any) -> None: + """Write a JSON payload to stdout.""" + + console.print_json(json=to_json_string(payload)) + + +def write_json_file(path: Path, payload: Any) -> None: + """Write JSON to disk.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(to_json_string(payload, indent=2) + "\n", encoding="utf-8") + + +def console_table( + title: str, columns: list[str], rows: Iterable[Iterable[Any]] +) -> Table: + """Build a simple rich table.""" + + table = Table(title=title) + for column in columns: + table.add_column(column) + for row in rows: + table.add_row(*[("" if item is None else str(item)) for item in row]) + return table + + +def console_kv_table(title: str, rows: Iterable[tuple[str, Any]]) -> Table: + """Build a two-column key/value table.""" + + table = Table(title=title) + table.add_column("Field") + table.add_column("Value") + for key, value in rows: + table.add_row(key, "" if value is None else str(value)) + return table + + +def print_json_text(console: Console, payload: Any) -> None: + """Pretty-print a JSON object in text mode.""" + + console.print_json(json=to_json_string(payload, indent=2)) + + +def format_color_value(value: Any) -> str: + """Return a compact human-friendly representation of reminder colors.""" + + if not value: + return "" + + payload = value + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return "" + if not stripped.startswith("{"): + return stripped + try: + payload = json.loads(stripped) + except json.JSONDecodeError: + return stripped + + if isinstance(payload, dict): + hex_value = payload.get("daHexString") + symbolic = payload.get("ckSymbolicColorName") or payload.get( + "daSymbolicColorName" + ) + if hex_value and symbolic and symbolic != "custom": + return f"{symbolic} ({hex_value})" + if hex_value: + return str(hex_value) + if symbolic: + return str(symbolic) + return to_json_string(payload) + + return str(payload) diff --git a/pyicloud/cmdline.py b/pyicloud/cmdline.py index dc8eddab..b4524425 100644 --- a/pyicloud/cmdline.py +++ b/pyicloud/cmdline.py @@ -1,520 +1,9 @@ -#! /usr/bin/env python -""" -A Command Line Wrapper to allow easy use of pyicloud for -command line scripts, and related. -""" +"""Backward-compatible command line entrypoint.""" -import argparse -import logging -import os -import pickle -import sys -from pprint import pformat -from typing import Any, Optional +from pyicloud.cli.app import main -from click import confirm - -from pyicloud import PyiCloudService, utils -from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable -from pyicloud.services.findmyiphone import AppleDevice -from pyicloud.ssl_context import configurable_ssl_verification - -DEVICE_ERROR = "Please use the --device switch to indicate which device to use." - - -def create_pickled_data(idevice: AppleDevice, filename: str) -> None: - """ - This helper will output the idevice to a pickled file named - after the passed filename. - - This allows the data to be used without resorting to screen / pipe - scrapping. - """ - with open(filename, "wb") as pickle_file: - pickle.dump( - idevice.data, - pickle_file, - protocol=pickle.HIGHEST_PROTOCOL, - ) - - -def _create_parser() -> argparse.ArgumentParser: - """Create the parser.""" - parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool") - - parser.add_argument( - "--username", - action="store", - dest="username", - default="", - help="Apple ID to Use", - ) - - parser.add_argument( - "--password", - action="store", - dest="password", - default="", - help=( - "Apple ID Password to Use; if unspecified, password will be " - "fetched from the system keyring." - ), - ) - - parser.add_argument( - "--china-mainland", - action="store_true", - dest="china_mainland", - default=False, - help="If the country/region setting of the Apple ID is China mainland", - ) - - parser.add_argument( - "-n", - "--non-interactive", - action="store_false", - dest="interactive", - default=True, - help="Disable interactive prompts.", - ) - - parser.add_argument( - "--delete-from-keyring", - action="store_true", - dest="delete_from_keyring", - default=False, - help="Delete stored password in system keyring for this username.", - ) - - # Group for listing options - list_group = parser.add_argument_group( - title="Listing Options", - description="Options for listing devices", - ) - - # Mutually exclusive group for listing options - list_type_group = list_group.add_mutually_exclusive_group() - list_type_group.add_argument( - "--list", - action="store_true", - dest="list", - default=False, - help="Short Listings for Device(s) associated with account", - ) - - list_type_group.add_argument( - "--llist", - action="store_true", - dest="longlist", - default=False, - help="Detailed Listings for Device(s) associated with account", - ) - - list_group.add_argument( - "--locate", - action="store_true", - dest="locate", - default=False, - help="Retrieve Location for the iDevice (non-exclusive).", - ) - - # Restrict actions to a specific devices UID / DID - parser.add_argument( - "--device", - action="store", - dest="device_id", - default=False, - help="Only effect this device", - ) - - # Trigger Sound Alert - parser.add_argument( - "--sound", - action="store_true", - dest="sound", - default=False, - help="Play a sound on the device", - ) - - # Trigger Message w/Sound Alert - parser.add_argument( - "--message", - action="store", - dest="message", - default=False, - help="Optional Text Message to display with a sound", - ) - - # Trigger Message (without Sound) Alert - parser.add_argument( - "--silentmessage", - action="store", - dest="silentmessage", - default=False, - help="Optional Text Message to display with no sounds", - ) - - # Lost Mode - parser.add_argument( - "--lostmode", - action="store_true", - dest="lostmode", - default=False, - help="Enable Lost mode for the device", - ) - - parser.add_argument( - "--lostphone", - action="store", - dest="lost_phone", - default=False, - help="Phone Number allowed to call when lost mode is enabled", - ) - - parser.add_argument( - "--lostpassword", - action="store", - dest="lost_password", - default=False, - help="Forcibly active this passcode on the idevice", - ) - - parser.add_argument( - "--lostmessage", - action="store", - dest="lost_message", - default="", - help="Forcibly display this message when activating lost mode.", - ) - - # Output device data to an pickle file - parser.add_argument( - "--outputfile", - action="store_true", - dest="output_to_file", - default="", - help="Save device data to a file in the current directory.", - ) - - parser.add_argument( - "--log-level", - action="store", - dest="loglevel", - choices=["error", "warning", "info", "none"], - default="info", - help="Set the logging level", - ) - - parser.add_argument( - "--debug", - action="store_true", - help="Enable debug logging", - ) - - parser.add_argument( - "--accept-terms", - action="store_true", - default=False, - help="Automatically accept terms and conditions", - ) - - parser.add_argument( - "--with-family", - action="store_true", - default=False, - help="Include family devices", - ) - - parser.add_argument( - "--session-dir", - type=str, - help="Directory to store session files", - ) - - parser.add_argument( - "--http-proxy", - type=str, - help="Use HTTP proxy for requests", - ) - - parser.add_argument( - "--https-proxy", - type=str, - help="Use HTTPS proxy for requests", - ) - - parser.add_argument( - "--no-verify-ssl", - action="store_true", - default=False, - help="Disable SSL certificate verification (WARNING: This makes the connection insecure)", - ) - - return parser - - -def _get_password( - username: str, - parser: argparse.ArgumentParser, - command_line: argparse.Namespace, -) -> Optional[str]: - """Which password we use is determined by your username, so we - do need to check for this first and separately.""" - if not username: - parser.error("No username supplied") - - password: Optional[str] = command_line.password - if not password: - password = utils.get_password(username, interactive=command_line.interactive) - - return password - - -def main() -> None: - """Main commandline entrypoint.""" - parser: argparse.ArgumentParser = _create_parser() - command_line: argparse.Namespace = parser.parse_args() - level = logging.INFO - - if command_line.loglevel == "error": - level = logging.ERROR - elif command_line.loglevel == "warning": - level = logging.WARNING - elif command_line.loglevel == "info": - level = logging.INFO - elif command_line.loglevel == "none": - level = None - - if command_line.debug: - level = logging.DEBUG - - if level: - logging.basicConfig(level=level) - - username: str = command_line.username.strip() - china_mainland: bool = command_line.china_mainland - - if username and command_line.delete_from_keyring: - utils.delete_password_in_keyring(username) - - with configurable_ssl_verification( - verify_ssl=not command_line.no_verify_ssl, - http_proxy=command_line.http_proxy or "", - https_proxy=command_line.https_proxy or "", - ): - password: Optional[str] = _get_password(username, parser, command_line) - - api: Optional[PyiCloudService] = _authenticate( - username, - password, - china_mainland, - parser, - command_line, - ) - - if not api: - return - _print_devices(api, command_line) - - -def _authenticate( - username: str, - password: Optional[str], - china_mainland: bool, - parser: argparse.ArgumentParser, - command_line: argparse.Namespace, -) -> Optional[PyiCloudService]: - api = None - try: - api = PyiCloudService( - apple_id=username, - password=password, - china_mainland=china_mainland, - cookie_directory=command_line.session_dir, - accept_terms=command_line.accept_terms, - with_family=command_line.with_family, - ) - if ( - not utils.password_exists_in_keyring(username) - and command_line.interactive - and confirm("Save password in keyring?") - and password - ): - utils.store_password_in_keyring(username, password) - - if api.requires_2fa: - _handle_2fa(api) - - elif api.requires_2sa: - _handle_2sa(api) - return api - except PyiCloudFailedLoginException as err: - # If they have a stored password; we just used it and - # it did not work; let's delete it if there is one. - if not password: - parser.error("No password supplied") - - if utils.password_exists_in_keyring(username): - utils.delete_password_in_keyring(username) - - message: str = f"Bad username or password for {username}" - - print(err, file=sys.stderr) - - raise RuntimeError(message) from err - - -def _print_devices(api: PyiCloudService, command_line: argparse.Namespace) -> None: - try: - print(f"Number of devices: {len(api.devices)}", flush=True) - for dev in api.devices: - if not command_line.device_id or ( - command_line.device_id.strip().lower() == dev.id.strip().lower() - ): - # List device(s) - _list_devices_option(command_line, dev) - - # Play a Sound on a device - _play_device_sound_option(command_line, dev) - - # Display a Message on the device - _display_device_message_option(command_line, dev) - - # Display a Silent Message on the device - _display_device_silent_message_option(command_line, dev) - - # Enable Lost mode - _enable_lost_mode_option(command_line, dev) - except PyiCloudServiceUnavailable: - print("iCloud - Find My service is unavailable.") - - -def _enable_lost_mode_option( - command_line: argparse.Namespace, dev: AppleDevice -) -> None: - if command_line.lostmode: - if command_line.device_id: - dev.lost_device( - number=command_line.lost_phone.strip(), - text=command_line.lost_message.strip(), - newpasscode=command_line.lost_password.strip(), - ) - else: - raise RuntimeError( - f"Lost Mode can only be activated on a singular device. {DEVICE_ERROR}" - ) - - -def _display_device_silent_message_option( - command_line: argparse.Namespace, dev: AppleDevice -) -> None: - if command_line.silentmessage: - if command_line.device_id: - dev.display_message( - subject="A Silent Message", - message=command_line.silentmessage, - sounds=False, - ) - else: - raise RuntimeError( - f"Silent Messages can only be played on a singular device. {DEVICE_ERROR}" - ) - - -def _display_device_message_option( - command_line: argparse.Namespace, dev: AppleDevice -) -> None: - if command_line.message: - if command_line.device_id: - dev.display_message( - subject="A Message", message=command_line.message, sounds=True - ) - else: - raise RuntimeError( - f"Messages can only be played on a singular device. {DEVICE_ERROR}" - ) - - -def _play_device_sound_option( - command_line: argparse.Namespace, dev: AppleDevice -) -> None: - if command_line.sound: - if command_line.device_id: - dev.play_sound() - else: - raise RuntimeError( - f"\n\n\t\tSounds can only be played on a singular device. {DEVICE_ERROR}\n\n" - ) - - -def _list_devices_option(command_line: argparse.Namespace, dev: AppleDevice) -> None: - location = dev.location if command_line.locate else None - - if command_line.output_to_file: - create_pickled_data( - dev, - filename=(dev.name.strip().lower() + ".fmip_snapshot"), - ) - - if command_line.longlist: - print("-" * 30) - print(dev.name) - for key in dev.data: - print( - f"{key:>30} - {pformat(dev.data[key]).replace(os.linesep, os.linesep + ' ' * 33)}" - ) - elif command_line.list: - print("-" * 30) - print(f"Name - {dev.name}") - print(f"Display Name - {dev.deviceDisplayName}") - print(f"Location - {location or dev.location}") - print(f"Battery Level - {dev.batteryLevel}") - print(f"Battery Status - {dev.batteryStatus}") - print(f"Device Class - {dev.deviceClass}") - print(f"Device Model - {dev.deviceModel}") - - -def _handle_2fa(api: PyiCloudService) -> None: - print("\nTwo-step authentication required.", "\nPlease enter validation code") - - code: str = input("(string) --> ") - if not api.validate_2fa_code(code): - print("Failed to verify verification code") - sys.exit(1) - - print("") - - -def _handle_2sa(api: PyiCloudService) -> None: - print("\nTwo-step authentication required.", "\nYour trusted devices are:") - - devices: list[dict[str, Any]] = _show_devices(api) - - print("\nWhich device would you like to use?") - device_idx = int(input("(number) --> ")) - device: dict[str, Any] = devices[device_idx] - if not api.send_verification_code(device): - print("Failed to send verification code") - sys.exit(1) - - print("\nPlease enter validation code") - code: str = input("(string) --> ") - if not api.validate_verification_code(device, code): - print("Failed to verify verification code") - sys.exit(1) - - print("") - - -def _show_devices(api: PyiCloudService) -> list[dict[str, Any]]: - """Show devices.""" - devices: list[dict[str, Any]] = api.trusted_devices - for i, device in enumerate(devices): - phone_number: str = f"{device.get('deviceType')} to {device.get('phoneNumber')}" - print(f" {i}: {device.get('deviceName', phone_number)}") - - return devices +__all__ = ["main"] if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt index 9dc0bd8c..f2e8da2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ keyring>=25.6.0 keyrings.alt>=5.0.2 requests>=2.31.0 srp>=1.0.21 +typer>=0.16.1 tzlocal==5.3.1 diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index f5c7064b..fd4ababd 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -1,423 +1,806 @@ -"""Cmdline tests.""" -# pylint: disable=protected-access - -import argparse -import pickle -from io import BytesIO -from pprint import pformat -from unittest.mock import MagicMock, PropertyMock, mock_open, patch - -import pytest - -from pyicloud.cmdline import ( - _create_parser, - _display_device_message_option, - _display_device_silent_message_option, - _enable_lost_mode_option, - _handle_2fa, - _handle_2sa, - _list_devices_option, - _play_device_sound_option, - create_pickled_data, - main, -) -from pyicloud.services.findmyiphone import AppleDevice -from tests import PyiCloudSessionMock -from tests.const import ( - AUTHENTICATED_USER, - FMI_FAMILY_WORKING, - REQUIRES_2FA_USER, - VALID_2FA_CODE, - VALID_PASSWORD, -) - - -def test_no_arg() -> None: - """Test no args.""" - with pytest.raises(SystemExit, match="2"): - main() - - -def test_username_password_invalid() -> None: - """Test username and password commands.""" - # No password supplied - with ( - patch("getpass.getpass", return_value=None), - patch("argparse.ArgumentParser.parse_args") as mock_parse_args, - patch("builtins.open", new_callable=mock_open), - patch("pyicloud.base.makedirs"), - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), - pytest.raises(SystemExit, match="2"), - ): - mock_parse_args.return_value = argparse.Namespace( - username="valid_user", - password=None, - debug=False, - interactive=True, - china_mainland=False, - delete_from_keyring=False, - loglevel="info", - no_verify_ssl=False, - http_proxy=None, - https_proxy=None, - session_dir="./", - accept_terms=False, - with_family=False, - ) - main() +"""Tests for the Typer-based pyicloud CLI.""" + +from __future__ import annotations + +import importlib +import json +from contextlib import nullcontext +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Iterable, Optional +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +cli_module = importlib.import_module("pyicloud.cli.app") +context_module = importlib.import_module("pyicloud.cli.context") +app = cli_module.app + + +class FakeDevice: + """Find My device fixture.""" + + def __init__(self) -> None: + self.id = "device-1" + self.name = "Jacob's iPhone" + self.deviceDisplayName = "iPhone" + self.deviceClass = "iPhone" + self.deviceModel = "iPhone16,1" + self.batteryLevel = 0.87 + self.batteryStatus = "Charging" + self.location = {"latitude": 49.0, "longitude": 6.0} + self.data = { + "id": self.id, + "name": self.name, + "deviceDisplayName": self.deviceDisplayName, + "deviceClass": self.deviceClass, + "deviceModel": self.deviceModel, + "batteryLevel": self.batteryLevel, + "batteryStatus": self.batteryStatus, + "location": self.location, + } + self.sound_subject: Optional[str] = None + self.messages: list[dict[str, Any]] = [] + self.lost_mode: Optional[dict[str, str]] = None + self.erase_message: Optional[str] = None + + def play_sound(self, subject: str = "Find My iPhone Alert") -> None: + self.sound_subject = subject + + def display_message(self, subject: str, message: str, sounds: bool) -> None: + self.messages.append({"subject": subject, "message": message, "sounds": sounds}) + + def lost_device(self, number: str, text: str, newpasscode: str) -> None: + self.lost_mode = {"number": number, "text": text, "newpasscode": newpasscode} + + def erase_device(self, message: str) -> None: + self.erase_message = message + + +class FakeDriveResponse: + """Download response fixture.""" + + def iter_content(self, chunk_size: int = 8192): # pragma: no cover - trivial + yield b"hello" + + +class FakeDriveNode: + """Drive node fixture.""" + + def __init__( + self, + name: str, + *, + node_type: str = "folder", + size: Optional[int] = None, + modified: Optional[datetime] = None, + children: Optional[list["FakeDriveNode"]] = None, + ) -> None: + self.name = name + self.type = node_type + self.size = size + self.date_modified = modified + self._children = children or [] + self.data = {"name": name, "type": node_type, "size": size} + + def get_children(self) -> list["FakeDriveNode"]: + return list(self._children) + + def __getitem__(self, key: str) -> "FakeDriveNode": + for child in self._children: + if child.name == key: + return child + raise KeyError(key) + + def open(self, **kwargs) -> FakeDriveResponse: # pragma: no cover - trivial + return FakeDriveResponse() + + +class FakeAlbumContainer(list): + """Photo album container fixture.""" + + def find(self, name: Optional[str]): + if name is None: + return None + for album in self: + if album.name == name: + return album + return None + + +class FakePhoto: + """Photo asset fixture.""" + + def __init__(self, photo_id: str, filename: str) -> None: + self.id = photo_id + self.filename = filename + self.item_type = "image" + self.created = datetime(2026, 3, 1, tzinfo=timezone.utc) + self.size = 1234 + + def download(self, version: str = "original") -> bytes: + return f"{self.id}:{version}".encode() + + +class FakePhotoAlbum: + """Photo album fixture.""" + + def __init__(self, name: str, photos: list[FakePhoto]) -> None: + self.name = name + self.fullname = f"/{name}" + self._photos = photos - # Bad username or password - with ( - patch("getpass.getpass", return_value="invalid_pass"), - patch("argparse.ArgumentParser.parse_args") as mock_parse_args, - patch("builtins.open", new_callable=mock_open), - patch("pyicloud.base.makedirs"), - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), - pytest.raises(RuntimeError, match="Bad username or password for invalid_user"), - ): - mock_parse_args.return_value = argparse.Namespace( - username="invalid_user", - password=None, - debug=False, - interactive=True, - china_mainland=False, - delete_from_keyring=False, - loglevel="error", - no_verify_ssl=True, - http_proxy=None, - https_proxy=None, - session_dir="./", - accept_terms=False, - with_family=False, - ) - main() + @property + def photos(self): + return iter(self._photos) - # We should not use getpass for this one, but we reset the password at login fail - with ( - patch("argparse.ArgumentParser.parse_args") as mock_parse_args, - patch("builtins.open", new_callable=mock_open), - patch("pyicloud.base.makedirs"), - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), - pytest.raises(RuntimeError, match="Bad username or password for invalid_user"), - ): - mock_parse_args.return_value = argparse.Namespace( - username="invalid_user", - password="invalid_pass", - debug=False, - interactive=False, - china_mainland=False, - delete_from_keyring=False, - loglevel="warning", - no_verify_ssl=False, - http_proxy="http://proxy:8080", - https_proxy="https://proxy:8080", - session_dir="./", - accept_terms=True, - with_family=True, + def __len__(self) -> int: + return len(self._photos) + + def __getitem__(self, photo_id: str) -> FakePhoto: + for photo in self._photos: + if photo.id == photo_id: + return photo + raise KeyError(photo_id) + + +class FakeHideMyEmail: + """Hide My Email fixture.""" + + def __init__(self) -> None: + self.aliases = [ + { + "hme": "alpha@privaterelay.appleid.com", + "label": "Shopping", + "anonymousId": "alias-1", + } + ] + + def __iter__(self): + return iter(self.aliases) + + def generate(self) -> str: + return "generated@privaterelay.appleid.com" + + def reserve( + self, email: str, label: str, note: str = "Generated" + ) -> dict[str, Any]: + return {"anonymousId": "alias-2", "hme": email, "label": label, "note": note} + + def update_metadata( + self, anonymous_id: str, label: str, note: str + ) -> dict[str, Any]: + return {"anonymousId": anonymous_id, "label": label, "note": note} + + def deactivate(self, anonymous_id: str) -> dict[str, Any]: + return {"anonymousId": anonymous_id, "active": False} + + def reactivate(self, anonymous_id: str) -> dict[str, Any]: + return {"anonymousId": anonymous_id, "active": True} + + def delete(self, anonymous_id: str) -> dict[str, Any]: + return {"anonymousId": anonymous_id, "deleted": True} + + +@dataclass +class FakeReminder: + """Reminder fixture.""" + + id: str + title: str + completed: bool = False + due_date: Optional[datetime] = None + priority: int = 0 + desc: str = "" + + +@dataclass +class FakeNoteSummary: + """Note summary fixture.""" + + id: str + title: str + folder_name: str + modified_at: datetime + is_deleted: bool = False + + +@dataclass +class FakeNote: + """Note fixture.""" + + id: str + title: str + text: str + attachments: Optional[list[Any]] = None + + +@dataclass +class FakeChange: + """Change fixture.""" + + type: str + reminder_id: Optional[str] = None + reminder: Optional[Any] = None + note_id: Optional[str] = None + note: Optional[Any] = None + + +class FakeReminders: + """Reminders service fixture.""" + + def __init__(self) -> None: + self._lists = [ + SimpleNamespace( + id="list-1", + title="Inbox", + color='{"daHexString":"#007AFF","ckSymbolicColorName":"blue"}', + count=2, + ) + ] + self._reminders = [ + FakeReminder(id="rem-1", title="Buy milk", priority=1), + FakeReminder(id="rem-2", title="Pay rent", completed=True), + ] + + def lists(self) -> Iterable[Any]: + return list(self._lists) + + def reminders( + self, list_id: Optional[str] = None, include_completed: bool = False + ) -> Iterable[FakeReminder]: + if include_completed: + return list(self._reminders) + return [reminder for reminder in self._reminders if not reminder.completed] + + def get(self, reminder_id: str) -> FakeReminder: + for reminder in self._reminders: + if reminder.id == reminder_id: + return reminder + raise KeyError(reminder_id) + + def create(self, **kwargs: Any) -> FakeReminder: + reminder = FakeReminder( + id="rem-created", + title=kwargs["title"], + due_date=kwargs.get("due_date"), + priority=kwargs.get("priority", 0), + desc=kwargs.get("desc", ""), ) - main() + self._reminders.append(reminder) + return reminder + def update(self, reminder: FakeReminder) -> None: + return None -def test_username_password_requires_2fa() -> None: - """Test username and password commands.""" - # Valid connection for the first time - with ( - patch("argparse.ArgumentParser.parse_args") as mock_parse_args, - patch("pyicloud.cmdline.input", return_value=VALID_2FA_CODE), - patch("pyicloud.cmdline.confirm", return_value=False), - patch("keyring.get_password", return_value=None), - patch("builtins.open", new_callable=mock_open), - patch("pyicloud.base.makedirs"), - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), - ): - mock_parse_args.return_value = argparse.Namespace( - username=REQUIRES_2FA_USER, - password=VALID_PASSWORD, - debug=False, - interactive=True, - china_mainland=False, - delete_from_keyring=False, - device_id=None, - locate=None, - output_to_file=None, - longlist=None, - list=None, - sound=None, - message=None, - silentmessage=None, - lostmode=None, - loglevel="warning", - no_verify_ssl=True, - http_proxy=None, - https_proxy=None, - session_dir="./", - accept_terms=False, - with_family=False, + def delete(self, reminder: FakeReminder) -> None: + reminder.completed = True + + def iter_changes(self, since: Optional[str] = None): + yield FakeChange( + type="updated", reminder_id="rem-1", reminder=self._reminders[0] ) - main() -def test_device_outputfile(mock_file_open_write_fixture: MagicMock) -> None: - """Test the outputfile command.""" +class FakeNotes: + """Notes service fixture.""" + + def __init__(self) -> None: + self._recent = [ + FakeNoteSummary( + id="note-deleted", + title="Deleted Note", + folder_name="Recently Deleted", + modified_at=datetime(2026, 3, 2, tzinfo=timezone.utc), + is_deleted=True, + ), + FakeNoteSummary( + id="note-1", + title="Daily Plan", + folder_name="Notes", + modified_at=datetime(2026, 3, 1, tzinfo=timezone.utc), + ), + ] + self._folders = [ + SimpleNamespace( + id="folder-1", + name="Notes", + parent_id=None, + has_subfolders=False, + ) + ] + + def recents(self, *, limit: int = 50): + return self._recent[:limit] + + def folders(self): + return list(self._folders) + + def in_folder(self, folder_id: str, limit: int = 50): + return self._recent[:limit] + + def iter_all(self, since: Optional[str] = None): + return iter(self._recent) + + def get(self, note_id: str, *, with_attachments: bool = False): + attachments = ( + [ + SimpleNamespace( + id="att-1", filename="file.pdf", uti="com.adobe.pdf", size=12 + ) + ] + if with_attachments + else None + ) + return FakeNote( + id=note_id, title="Daily Plan", text="Ship CLI", attachments=attachments + ) - with ( - patch("argparse.ArgumentParser.parse_args") as mock_parse_args, - patch("builtins.open", mock_file_open_write_fixture), - patch("pyicloud.base.makedirs"), - patch("keyring.get_password", return_value=None), - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), - ): - mock_parse_args.return_value = argparse.Namespace( - username=AUTHENTICATED_USER, - password=VALID_PASSWORD, - debug=False, - interactive=False, - china_mainland=False, - delete_from_keyring=False, - device_id=None, - locate=None, - output_to_file=True, - longlist=None, - list=None, - sound=None, - message=None, - silentmessage=None, - lostmode=None, - loglevel="none", - no_verify_ssl=True, - http_proxy=None, - https_proxy=None, - session_dir="./", - accept_terms=False, - with_family=False, + def render_note(self, note_id: str, **kwargs: Any) -> str: + return f"

{note_id}

" + + def export_note(self, note_id: str, output_dir: str, **kwargs: Any) -> str: + return str(Path(output_dir) / f"{note_id}.html") + + def iter_changes(self, since: Optional[str] = None): + yield FakeChange(type="updated", note_id="note-1", note=self.get("note-1")) + + +class FakeAPI: + """Authenticated API fixture.""" + + def __init__(self) -> None: + self.requires_2fa = False + self.requires_2sa = False + self.is_trusted_session = True + self.fido2_devices: list[dict[str, Any]] = [] + self.trusted_devices: list[dict[str, Any]] = [] + self.validate_2fa_code = MagicMock(return_value=True) + self.confirm_security_key = MagicMock(return_value=True) + self.send_verification_code = MagicMock(return_value=True) + self.validate_verification_code = MagicMock(return_value=True) + self.trust_session = MagicMock(return_value=True) + self.account_name = "user@example.com" + self.devices = [FakeDevice()] + self.account = SimpleNamespace( + devices=[ + { + "name": "Jacob's iPhone", + "modelDisplayName": "iPhone 16 Pro", + "deviceClass": "iPhone", + "id": "acc-device-1", + } + ], + family=[ + SimpleNamespace( + full_name="Jane Doe", + apple_id="jane@example.com", + dsid="123", + age_classification="adult", + has_parental_privileges=True, + ) + ], + storage=SimpleNamespace( + usage=SimpleNamespace( + used_storage_in_bytes=100, + available_storage_in_bytes=900, + total_storage_in_bytes=1000, + used_storage_in_percent=10.0, + ), + usages_by_media={ + "photos": SimpleNamespace( + label="Photos", color="FFFFFF", usage_in_bytes=80 + ) + }, + ), + summary_plan={"summary": {"limit": 50, "limitUnits": "GIB"}}, + ) + self.calendar = SimpleNamespace( + get_calendars=lambda: [ + { + "guid": "cal-1", + "title": "Home", + "color": "#fff", + "shareType": "owner", + } + ], + get_events=lambda **kwargs: [ + { + "guid": "event-1", + "pGuid": "cal-1", + "title": "Dentist", + "startDate": "2026-03-01T09:00:00Z", + "endDate": "2026-03-01T10:00:00Z", + } + ], + ) + self.contacts = SimpleNamespace( + all=[ + { + "firstName": "John", + "lastName": "Appleseed", + "phones": [{"field": "+1 555-0100"}], + "emails": [{"field": "john@example.com"}], + } + ], + me=SimpleNamespace( + first_name="John", + last_name="Appleseed", + photo={"url": "https://example.com/photo.jpg"}, + raw_data={"contacts": [{"firstName": "John"}]}, + ), ) - main() - - devices = FMI_FAMILY_WORKING.get("content") - if devices: - for device in devices: - file_name = device.get("name").strip().lower() + ".fmip_snapshot" - assert file_name in mock_file_open_write_fixture.written_data - buffer = BytesIO(mock_file_open_write_fixture.written_data[file_name]) - - contents = [] - while True: - try: - contents.append(pickle.load(buffer)) - except EOFError: - break - assert contents == [device] - - -def test_create_pickled_data() -> None: - """Test the creation of pickled data.""" - idevice = MagicMock() - idevice.data = {"key": "value"} - filename = "test.pkl" + drive_file = FakeDriveNode( + "report.txt", + node_type="file", + size=42, + modified=datetime(2026, 3, 1, tzinfo=timezone.utc), + ) + self.drive = SimpleNamespace( + root=FakeDriveNode("root", children=[drive_file]), + trash=FakeDriveNode("trash"), + ) + photo_album = FakePhotoAlbum("All Photos", [FakePhoto("photo-1", "img.jpg")]) + self.photos = SimpleNamespace( + albums=FakeAlbumContainer([photo_album]), + all=photo_album, + ) + self.hidemyemail = FakeHideMyEmail() + self.reminders = FakeReminders() + self.notes = FakeNotes() + + +def _runner() -> CliRunner: + return CliRunner() + + +def _invoke( + fake_api: FakeAPI, + *args: str, + interactive: bool = False, +): + runner = _runner() + cli_args = [ + "--username", + "user@example.com", + "--password", + "secret", + *([] if interactive else ["--non-interactive"]), + *args, + ] with ( - patch("builtins.open", new_callable=mock_open) as mock_file, - patch("pickle.dump") as mock_pickle_dump, - patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), + patch.object(context_module, "PyiCloudService", return_value=fake_api), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), ): - create_pickled_data(idevice, filename) - mock_file.assert_called_with(filename, "wb") - mock_pickle_dump.assert_called_with( - idevice.data, mock_file(), protocol=pickle.HIGHEST_PROTOCOL - ) + return runner.invoke(app, cli_args) + + +def test_root_help() -> None: + """The root command should expose the service subcommands and format option.""" + + result = _runner().invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "--format" in result.stdout + assert "--json" not in result.stdout + assert "--debug" not in result.stdout + for command in ( + "account", + "devices", + "calendar", + "contacts", + "drive", + "photos", + "hidemyemail", + "reminders", + "notes", + ): + assert command in result.stdout + + +def test_group_help() -> None: + """Each command group should expose help.""" + + for command in ( + "account", + "devices", + "calendar", + "contacts", + "drive", + "photos", + "hidemyemail", + "reminders", + "notes", + ): + result = _runner().invoke(app, [command, "--help"]) + assert result.exit_code == 0 -def test_create_parser() -> None: - """Test the creation of the parser.""" - parser: argparse.ArgumentParser = _create_parser() - assert isinstance(parser, argparse.ArgumentParser) +def test_account_summary_command() -> None: + """Account summary should render the storage overview.""" + result = _invoke(FakeAPI(), "account", "summary") + assert result.exit_code == 0 + assert "Account: user@example.com" in result.stdout + assert "Storage: 10.0% used" in result.stdout -def test_enable_lost_mode_option() -> None: - """Test the enable lost mode option.""" - command_line = MagicMock( - lostmode=True, - device_id="123", - lost_phone="1234567890", - lost_message="Lost", - lost_password="pass", - ) - dev = MagicMock() - _enable_lost_mode_option(command_line, dev) - dev.lost_device.assert_called_with( - number="1234567890", text="Lost", newpasscode="pass" - ) +def test_format_option_outputs_json() -> None: + """The root format option should support machine-readable JSON.""" -def test_display_device_message_option() -> None: - """Test the display device message option.""" - command_line = MagicMock(message="Test Message", device_id="123") - dev = MagicMock() - _display_device_message_option(command_line, dev) - dev.display_message.assert_called_with( - subject="A Message", message="Test Message", sounds=True - ) + result = _invoke(FakeAPI(), "--format", "json", "account", "summary") + payload = json.loads(result.stdout) + assert result.exit_code == 0 + assert payload["account_name"] == "user@example.com" + assert payload["devices_count"] == 1 -def test_display_device_silent_message_option() -> None: - """Test the display device silent message option.""" - command_line = MagicMock(silentmessage="Silent Message", device_id="123") - dev = MagicMock() - _display_device_silent_message_option(command_line, dev) - dev.display_message.assert_called_with( - subject="A Silent Message", message="Silent Message", sounds=False - ) +def test_default_log_level_is_warning() -> None: + """Authenticated commands should default pyicloud logs to warning.""" + with patch.object(context_module.logging, "basicConfig") as basic_config: + result = _invoke(FakeAPI(), "account", "summary") + assert result.exit_code == 0 + basic_config.assert_called_once_with(level=context_module.logging.WARNING) -def test_play_device_sound_option() -> None: - """Test the play device sound option.""" - command_line = MagicMock(sound=True, device_id="123") - dev = MagicMock() - _play_device_sound_option(command_line, dev) - dev.play_sound.assert_called_once() +def test_missing_username_errors_cleanly() -> None: + """Authenticated commands should fail without a traceback when username is missing.""" -def test_handle_2sa() -> None: - """Test the handle 2sa function.""" - api = MagicMock() - api.send_verification_code.return_value = True - api.validate_verification_code.return_value = True - with ( - patch("pyicloud.cmdline.input", side_effect=["0", "123456"]), - patch( - "pyicloud.cmdline._show_devices", - return_value=[{"deviceName": "Test Device"}], - ), + with patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() ): - _handle_2sa(api) - - api.send_verification_code.assert_called_once_with( - {"deviceName": "Test Device"} - ) - api.validate_verification_code.assert_called_once_with( - {"deviceName": "Test Device"}, - "123456", - ) + result = _runner().invoke(app, ["account", "summary"]) + assert result.exit_code != 0 + assert "The --username option is required" in result.exception.args[0] -def test_handle_2fa() -> None: - """Test the handle 2fa function.""" - api = MagicMock() - api.validate_2fa_code.return_value = True - with patch("pyicloud.cmdline.input", return_value="123456"): - _handle_2fa(api) - api.validate_2fa_code.assert_called_once_with("123456") +def test_delete_from_keyring() -> None: + """The keyring delete path should work without invoking a subcommand.""" - -def test_list_devices_option_locate() -> None: - """Test the list devices option with locate.""" - # Create a mock command_line object with the locate option enabled - command_line = MagicMock( - locate=True, # Enable the locate option - longlist=False, - output_to_file=False, - list=False, + with ( + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=True + ), + patch.object( + context_module.utils, "delete_password_in_keyring" + ) as delete_password, + ): + result = _runner().invoke( + app, + ["--username", "user@example.com", "--delete-from-keyring"], + ) + assert result.exit_code == 0 + delete_password.assert_called_once_with("user@example.com") + assert "Deleted stored password from keyring." in result.stdout + + +def test_security_key_flow() -> None: + """Security-key 2FA should confirm the selected key.""" + + fake_api = FakeAPI() + fake_api.requires_2fa = True + fake_api.fido2_devices = [{"id": "sk-1"}] + result = _invoke(fake_api, "account", "summary") + assert result.exit_code == 0 + fake_api.confirm_security_key.assert_called_once_with({"id": "sk-1"}) + + +def test_trusted_device_2sa_flow() -> None: + """2SA should send and validate a verification code.""" + + fake_api = FakeAPI() + fake_api.requires_2sa = True + fake_api.trusted_devices = [{"deviceName": "Trusted Device", "phoneNumber": "+1"}] + with patch.object(context_module.typer, "prompt", return_value="123456"): + result = _invoke(fake_api, "account", "summary", interactive=True) + assert result.exit_code == 0 + fake_api.send_verification_code.assert_called_once_with(fake_api.trusted_devices[0]) + fake_api.validate_verification_code.assert_called_once_with( + fake_api.trusted_devices[0], "123456" ) - # Create a mock device object - - dev = MagicMock() - location = PropertyMock(return_value="Test Location") - type(dev).location = location - - # Call the function - _list_devices_option(command_line, dev) - - # Verify that the location() method was called - location.assert_called_once() +def test_devices_list_and_show_commands() -> None: + """Devices list and show should expose summary and detailed views.""" -def test_list_devices_option() -> None: - """Test the list devices option.""" - command_line = MagicMock( - longlist=True, - locate=False, - output_to_file=False, - list=False, + fake_api = FakeAPI() + list_result = _invoke(fake_api, "devices", "list", "--locate") + show_result = _invoke(fake_api, "devices", "show", "device-1") + raw_result = _invoke( + fake_api, "--format", "json", "devices", "show", "device-1", "--raw" ) - content: dict[str, str] = { - "name": "Test Device", - "deviceDisplayName": "Test Display", - "location": "Test Location", - "batteryLevel": "100%", - "batteryStatus": "Charging", - "deviceClass": "Phone", - "deviceModel": "iPhone", - } - dev = AppleDevice( - content=content, - params={}, - manager=MagicMock(), - sound_url="", - lost_url="", - message_url="", - erase_token_url="", - erase_url="", + assert list_result.exit_code == 0 + assert "Jacob's iPhone" in list_result.stdout + assert show_result.exit_code == 0 + assert "Battery Status" in show_result.stdout + assert raw_result.exit_code == 0 + assert json.loads(raw_result.stdout)["deviceDisplayName"] == "iPhone" + + +def test_devices_mutations_and_export() -> None: + """Device actions should map to the Find My device methods.""" + + fake_api = FakeAPI() + export_path = Path("/tmp/python-test-results/test_cmdline/device.json") + export_path.parent.mkdir(parents=True, exist_ok=True) + sound_result = _invoke( + fake_api, + "--format", + "json", + "devices", + "sound", + "device-1", + "--subject", + "Ping", ) - - with patch("pyicloud.cmdline.create_pickled_data") as mock_create_pickled: - _list_devices_option(command_line, dev) - - # Verify no pickled data creation - mock_create_pickled.assert_not_called() - - # Check for proper console output during detailed listing - with patch("builtins.print") as mock_print: - _list_devices_option(command_line, dev) - mock_print.assert_any_call("-" * 30) - mock_print.assert_any_call("Test Device") - for key, value in content.items(): - mock_print.assert_any_call(f"{key:>30} - {pformat(value)}") - - -def test_list_devices_option_short_list() -> None: - """Test the list devices option with short list.""" - # Create a mock command_line object with the list option enabled - command_line = MagicMock( - longlist=False, - locate=False, - output_to_file=False, - list=True, # Enable the short list option + silent_result = _invoke( + fake_api, + "devices", + "message", + "device-1", + "Hello", + "--silent", ) - - # Create a mock device with sample content - content: dict[str, str | list[dict[str, bool]]] = { - "name": "Test Device", - "deviceDisplayName": "Test Display", - "location": "Test Location", - "batteryLevel": "100%", - "batteryStatus": "Charging", - "deviceClass": "Phone", - "deviceModel": "iPhone", - "features": [ - {"LOC": True}, - ], + lost_result = _invoke( + fake_api, + "devices", + "lost-mode", + "device-1", + "--phone", + "123", + "--message", + "Lost", + "--passcode", + "4567", + ) + export_result = _invoke( + fake_api, + "--format", + "json", + "devices", + "export", + "device-1", + "--output", + str(export_path), + ) + assert sound_result.exit_code == 0 + assert json.loads(sound_result.stdout)["subject"] == "Ping" + assert fake_api.devices[0].sound_subject == "Ping" + assert silent_result.exit_code == 0 + assert fake_api.devices[0].messages[-1]["sounds"] is False + assert lost_result.exit_code == 0 + assert fake_api.devices[0].lost_mode == { + "number": "123", + "text": "Lost", + "newpasscode": "4567", } - dev = AppleDevice( - content=content, - params={}, - manager=MagicMock(), - sound_url="", - lost_url="", - message_url="", - erase_token_url="", - erase_url="", + assert export_result.exit_code == 0 + assert json.loads(export_result.stdout)["path"] == str(export_path) + assert ( + json.loads(export_path.read_text(encoding="utf-8"))["name"] == "Jacob's iPhone" ) - with patch("builtins.print") as mock_print: - # Call the function - _list_devices_option(command_line, dev) - - # Verify the output for short list option - mock_print.assert_any_call("-" * 30) - mock_print.assert_any_call("Name - Test Device") - mock_print.assert_any_call("Display Name - Test Display") - mock_print.assert_any_call("Location - Test Location") - mock_print.assert_any_call("Battery Level - 100%") - mock_print.assert_any_call("Battery Status - Charging") - mock_print.assert_any_call("Device Class - Phone") - mock_print.assert_any_call("Device Model - iPhone") + +def test_calendar_and_contacts_commands() -> None: + """Calendar and contacts groups should expose read commands.""" + + fake_api = FakeAPI() + calendars = _invoke(fake_api, "calendar", "calendars") + contacts = _invoke(fake_api, "contacts", "me") + assert calendars.exit_code == 0 + assert "Home" in calendars.stdout + assert contacts.exit_code == 0 + assert "John Appleseed" in contacts.stdout + + +def test_drive_and_photos_commands() -> None: + """Drive and photos commands should expose listing and download flows.""" + + fake_api = FakeAPI() + output_path = Path("/tmp/python-test-results/test_cmdline/photo.bin") + output_path.parent.mkdir(parents=True, exist_ok=True) + drive_result = _invoke(fake_api, "drive", "list", "/") + photo_result = _invoke( + fake_api, + "photos", + "download", + "photo-1", + "--output", + str(output_path), + ) + assert drive_result.exit_code == 0 + assert "report.txt" in drive_result.stdout + assert photo_result.exit_code == 0 + assert output_path.read_bytes() == b"photo-1:original" + + +def test_hidemyemail_commands() -> None: + """Hide My Email commands should expose list and generate.""" + + fake_api = FakeAPI() + list_result = _invoke(fake_api, "hidemyemail", "list") + generate_result = _invoke(fake_api, "hidemyemail", "generate") + assert list_result.exit_code == 0 + assert "Shopping" in list_result.stdout + assert generate_result.exit_code == 0 + assert "generated@privaterelay.appleid.com" in generate_result.stdout + + +def test_reminders_commands() -> None: + """Reminders commands should expose list and create flows.""" + + fake_api = FakeAPI() + lists_result = _invoke(fake_api, "reminders", "lists") + list_result = _invoke(fake_api, "reminders", "list") + create_result = _invoke( + fake_api, + "--format", + "json", + "reminders", + "create", + "--list-id", + "list-1", + "--title", + "New task", + "--priority", + "5", + ) + assert lists_result.exit_code == 0 + assert "blue (#007AFF)" in lists_result.stdout + assert list_result.exit_code == 0 + assert "Buy milk" in list_result.stdout + assert create_result.exit_code == 0 + assert json.loads(create_result.stdout)["id"] == "rem-created" + + +def test_notes_commands() -> None: + """Notes commands should expose recent, get, render, and export flows.""" + + fake_api = FakeAPI() + output_dir = Path("/tmp/python-test-results/test_cmdline/notes") + output_dir.mkdir(parents=True, exist_ok=True) + recent_result = _invoke(fake_api, "notes", "recent", "--limit", "1") + include_deleted_result = _invoke( + fake_api, "notes", "recent", "--limit", "1", "--include-deleted" + ) + render_result = _invoke(fake_api, "--format", "json", "notes", "render", "note-1") + export_result = _invoke( + fake_api, + "--format", + "json", + "notes", + "export", + "note-1", + str(output_dir), + ) + assert recent_result.exit_code == 0 + assert "Daily Plan" in recent_result.stdout + assert "Deleted Note" not in recent_result.stdout + assert include_deleted_result.exit_code == 0 + assert "Deleted Note" in include_deleted_result.stdout + assert render_result.exit_code == 0 + assert json.loads(render_result.stdout)["html"] == "

note-1

" + assert export_result.exit_code == 0 + assert json.loads(export_result.stdout)["path"] == str(output_dir / "note-1.html") + + +def test_main_returns_clean_error_for_user_abort(capsys) -> None: + """The entrypoint should not emit a traceback for expected CLI errors.""" + + message = "The --username option is required for authenticated commands." + with patch.object(cli_module, "app", side_effect=context_module.CLIAbort(message)): + code = cli_module.main() + captured = capsys.readouterr() + assert code == 1 + assert message in captured.err From 14d7de12d6cb60b61a83b3a10e14db5fd4faffa1 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Mon, 16 Mar 2026 21:47:05 +0100 Subject: [PATCH 02/18] Finish Typer CLI contract cleanup --- README.md | 15 +++++++++++++++ pyicloud/cli/commands/devices.py | 23 +++++++++++++++-------- tests/test_cmdline.py | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1d5e365f..e7f5b098 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,21 @@ before the service subcommand: $ icloud --username=jappleseed@apple.com --format json account summary ``` +The main global options are: + +- `--username`: Apple ID username +- `--password`: Apple ID password; if omitted, pyicloud uses the system keyring or prompts interactively +- `--china-mainland`: use China mainland Apple web service endpoints +- `--interactive/--non-interactive`: enable or disable prompts for auth flows and keyring storage +- `--delete-from-keyring`: delete the stored password for `--username` and exit +- `--accept-terms`: automatically accept pending Apple web terms when possible +- `--with-family`: include family devices in Find My listings +- `--session-dir`: custom directory for session and cookie files +- `--http-proxy` / `--https-proxy`: per-protocol proxy settings +- `--no-verify-ssl`: disable TLS verification for requests +- `--log-level`: one of `error`, `warning`, `info`, or `debug` +- `--format`: one of `text` or `json` + You can store your password in the system keyring using the command-line tool: diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index fb657559..31276283 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -194,13 +194,16 @@ def devices_export( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), output: Path = typer.Option(..., "--output", help="Destination JSON file."), - raw: bool = typer.Option( - True, - "--raw/--normalized", - help="Write the raw device payload instead of normalized fields.", + raw: bool | None = typer.Option( + None, + "--raw", + help="Write the raw device payload.", ), - locate: bool = typer.Option( - False, "--locate", help="Include the current location in normalized exports." + normalized: bool = typer.Option( + False, + "--normalized", + hidden=True, + help="Write normalized device fields instead of the raw payload.", ), ) -> None: """Export a device snapshot to JSON.""" @@ -208,9 +211,13 @@ def devices_export( state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device) - payload = idevice.data if raw else normalize_device_details(idevice, locate=locate) + if raw and normalized: + raise typer.BadParameter("Choose either --raw or --normalized, not both.") + + use_raw = raw is not False and not normalized + payload = idevice.data if use_raw else normalize_device_details(idevice) write_json_file(output, payload) if state.json_output: - state.write_json({"device_id": idevice.id, "path": str(output), "raw": raw}) + state.write_json({"device_id": idevice.id, "path": str(output), "raw": use_raw}) return state.console.print(str(output)) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index fd4ababd..a7cebb9d 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -709,6 +709,7 @@ def test_drive_and_photos_commands() -> None: fake_api = FakeAPI() output_path = Path("/tmp/python-test-results/test_cmdline/photo.bin") + json_output_path = Path("/tmp/python-test-results/test_cmdline/report.txt") output_path.parent.mkdir(parents=True, exist_ok=True) drive_result = _invoke(fake_api, "drive", "list", "/") photo_result = _invoke( @@ -719,10 +720,22 @@ def test_drive_and_photos_commands() -> None: "--output", str(output_path), ) + json_drive_result = _invoke( + fake_api, + "--format", + "json", + "drive", + "download", + "/report.txt", + "--output", + str(json_output_path), + ) assert drive_result.exit_code == 0 assert "report.txt" in drive_result.stdout assert photo_result.exit_code == 0 assert output_path.read_bytes() == b"photo-1:original" + assert json_drive_result.exit_code == 0 + assert json.loads(json_drive_result.stdout)["path"] == str(json_output_path) def test_hidemyemail_commands() -> None: @@ -803,4 +816,5 @@ def test_main_returns_clean_error_for_user_abort(capsys) -> None: code = cli_module.main() captured = capsys.readouterr() assert code == 1 + assert captured.out == "" assert message in captured.err From 9a14203da5971f907c1d0d7ccfbaaba414b3a048 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Tue, 17 Mar 2026 22:28:48 +0100 Subject: [PATCH 03/18] Add explicit auth and session CLI --- README.md | 58 +++- pyicloud/base.py | 120 ++++++- pyicloud/cli/account_index.py | 131 +++++++ pyicloud/cli/app.py | 8 +- pyicloud/cli/commands/auth.py | 237 +++++++++++++ pyicloud/cli/context.py | 209 ++++++++++- pyicloud/session.py | 19 + tests/test_base.py | 209 +++++++++++ tests/test_cmdline.py | 634 ++++++++++++++++++++++++++++++++-- 9 files changed, 1570 insertions(+), 55 deletions(-) create mode 100644 pyicloud/cli/account_index.py create mode 100644 pyicloud/cli/commands/auth.py diff --git a/README.md b/README.md index e7f5b098..6fe45604 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,17 @@ api = PyiCloudService('jappleseed@apple.com', 'password', refresh_interval=60) # api.devices ``` -The `icloud` command line interface is organized around service -subcommands such as `account`, `devices`, `calendar`, `contacts`, -`drive`, `photos`, `hidemyemail`, `reminders`, and `notes`. +The `icloud` command line interface is organized around top-level +subcommands such as `account`, `auth`, `devices`, `calendar`, +`contacts`, `drive`, `photos`, `hidemyemail`, `reminders`, and +`notes`. Global options such as `--username`, `--password`, `--session-dir`, `--accept-terms`, `--with-family`, `--log-level`, and `--format` apply -before the service subcommand: +before the service subcommand. `--username` acts as an explicit override; +if exactly one local account is known in the selected session directory, +the CLI can infer it automatically. If multiple local accounts are known, +the CLI will ask you to pass `--username` explicitly. ```console $ icloud --username=jappleseed@apple.com --format json account summary @@ -70,7 +74,7 @@ $ icloud --username=jappleseed@apple.com --format json account summary The main global options are: -- `--username`: Apple ID username +- `--username`: Apple ID username; optional when exactly one local account is discoverable - `--password`: Apple ID password; if omitted, pyicloud uses the system keyring or prompts interactively - `--china-mainland`: use China mainland Apple web service endpoints - `--interactive/--non-interactive`: enable or disable prompts for auth flows and keyring storage @@ -100,10 +104,19 @@ the password for. Examples: ```console +$ icloud auth status +$ icloud auth login +$ icloud auth logout +$ icloud auth logout --keep-trusted +$ icloud auth logout --all-sessions +$ icloud auth logout --keep-trusted --all-sessions +$ icloud auth logout --remove-keyring +$ icloud account summary +$ icloud devices list --locate +$ icloud devices show "Jacob's iPhone" +$ icloud devices export "Jacob's iPhone" --output ./iphone.json +$ icloud --username=jappleseed@apple.com auth login $ icloud --username=jappleseed@apple.com account summary -$ icloud --username=jappleseed@apple.com devices list --locate -$ icloud --username=jappleseed@apple.com devices show "Jacob's iPhone" -$ icloud --username=jappleseed@apple.com devices export "Jacob's iPhone" --output ./iphone.json $ icloud --username=jappleseed@apple.com calendar events --period week $ icloud --username=jappleseed@apple.com contacts me $ icloud --username=jappleseed@apple.com drive list /Documents @@ -126,6 +139,35 @@ command-line option: $ icloud --username=jappleseed@apple.com --delete-from-keyring ``` +The `auth` command group lets you inspect and manage persisted sessions: + +- `icloud auth status`: report active logged-in iCloud sessions without prompting for password or 2FA +- `icloud auth login`: ensure a usable authenticated session exists +- `icloud auth logout`: sign out and clear the local session so the next login will typically require 2FA again +- `icloud auth logout --keep-trusted`: sign out while asking Apple to preserve trusted-browser state for the next login +- `icloud auth logout --all-sessions`: attempt to sign out all browser sessions +- `icloud auth logout --remove-keyring`: also delete the stored password for the selected account +- `icloud auth logout --keep-trusted --all-sessions`: experimental combination that requests both behaviors + +When only one local account is known, `auth login` can omit +`--username`. Service commands, `auth status`, and `auth logout` without +`--username` operate on active logged-in sessions only, similar to `gh`. +If no active sessions exist, service commands and `auth status` report +that no iCloud accounts are logged in and direct you to +`icloud --username= auth login`. If multiple logged-in +accounts exist, pass `--username` to disambiguate account-targeted +operations. + +`--keep-trusted` and `--all-sessions` are translated to Apple's logout +payload internally; the CLI intentionally exposes user-facing semantics +instead of the raw wire field names. + +Stored passwords in the system keyring are treated separately from +authenticated sessions. A plain `icloud auth logout` ends the session +but keeps the stored password. Use `icloud auth logout --remove-keyring` +or `icloud --username= --delete-from-keyring` if you also want +to forget the saved password. + Migration notes for the previous Find My-focused CLI: - `--list` now maps to `icloud devices list` diff --git a/pyicloud/base.py b/pyicloud/base.py index 8f8b8cce..d0c6c639 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -94,6 +94,16 @@ } +def resolve_cookie_directory(cookie_directory: Optional[str] = None) -> str: + """Resolve the directory used for persisted session and cookie data.""" + + if cookie_directory: + return path.normpath(path.expanduser(cookie_directory)) + + topdir: str = path.join(gettempdir(), "pyicloud") + return path.join(topdir, getpass.getuser()) + + class PyiCloudService: """ A base authentication class for the iCloud service. Handles the @@ -117,14 +127,11 @@ def _setup_endpoints(self) -> None: def _setup_cookie_directory(self, cookie_directory: Optional[str] = None) -> str: """Set up the cookie directory for the service.""" - _cookie_directory: str = "" - if cookie_directory: - _cookie_directory = path.normpath(path.expanduser(cookie_directory)) - else: - topdir: str = path.join(gettempdir(), "pyicloud") + _cookie_directory = resolve_cookie_directory(cookie_directory) + if not cookie_directory: + topdir = path.dirname(_cookie_directory) makedirs(topdir, exist_ok=True) chmod(topdir, 0o1777) - _cookie_directory = path.join(topdir, getpass.getuser()) old_umask = umask(0o077) try: @@ -144,6 +151,8 @@ def __init__( china_mainland: bool = False, accept_terms: bool = False, refresh_interval: float | None = None, + *, + authenticate: bool = True, ) -> None: self._is_china_mainland: bool = ( china_mainland or environ.get("icloud_china", "0") == "1" @@ -156,7 +165,7 @@ def __init__( self._accept_terms: bool = accept_terms self._refresh_interval: float | None = refresh_interval - if self._password_raw is None: + if self._password_raw is None and authenticate: self._password_raw = get_password_from_keyring(apple_id) self.data: dict[str, Any] = {} @@ -207,7 +216,8 @@ def __init__( self._requires_mfa: bool = False - self.authenticate() + if authenticate: + self.authenticate() def authenticate( self, force_refresh: bool = False, service: Optional[str] = None @@ -298,6 +308,100 @@ def _update_state(self) -> None: if "webservices" in self.data: self._webservices = self.data["webservices"] + def _clear_authenticated_state(self) -> None: + """Clear in-memory auth-derived state.""" + + self.data = {} + self._auth_data = {} + self._webservices = None + self._account = None + self._calendar = None + self._contacts = None + self._devices = None + self._drive = None + self._files = None + self._hidemyemail = None + self._photos = None + self._reminders = None + self._requires_mfa = False + self.params.pop("dsid", None) + + def get_auth_status(self) -> dict[str, Any]: + """Probe current authentication state without prompting for login.""" + + status: dict[str, Any] = { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + + if not self.session.data.get("session_token"): + self._clear_authenticated_state() + return status + + if not self.session.cookies.get("X-APPLE-WEBAUTH-TOKEN"): + self._clear_authenticated_state() + return status + + try: + self.data = self._validate_token() + self._update_state() + except PyiCloudAPIResponseException: + self._clear_authenticated_state() + return status + + status.update( + { + "authenticated": True, + "trusted_session": self.is_trusted_session, + "requires_2fa": self.requires_2fa, + "requires_2sa": self.requires_2sa, + } + ) + return status + + def logout( + self, + *, + keep_trusted: bool = False, + all_sessions: bool = False, + clear_local_session: bool = True, + ) -> dict[str, Any]: + """Log out of the current session and optionally clear local persistence.""" + + payload: dict[str, bool] = { + "trustBrowser": keep_trusted, + "allBrowsers": all_sessions, + } + remote_logout_confirmed = False + + if self.params.get("dsid") and self.session.cookies.get( + "X-APPLE-WEBAUTH-TOKEN" + ): + try: + response = self.session.post( + f"{self._setup_endpoint}/logout", + params=dict(self.params), + data=json.dumps(payload), + headers={"Content-Type": "text/plain;charset=UTF-8"}, + ) + remote_logout_confirmed = bool(response.json().get("success")) + except (PyiCloudAPIResponseException, ValueError): + LOGGER.debug("Remote logout was not confirmed.", exc_info=True) + + local_session_cleared = False + if clear_local_session: + self.session.clear_persistence(remove_files=True) + self._clear_authenticated_state() + local_session_cleared = True + + return { + "payload": payload, + "remote_logout_confirmed": remote_logout_confirmed, + "local_session_cleared": local_session_cleared, + } + def _authenticate(self) -> None: LOGGER.debug("Authenticating as %s", self.account_name) diff --git a/pyicloud/cli/account_index.py b/pyicloud/cli/account_index.py new file mode 100644 index 00000000..b45b596f --- /dev/null +++ b/pyicloud/cli/account_index.py @@ -0,0 +1,131 @@ +"""Local account discovery index for the Typer CLI.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, TypedDict + +ACCOUNT_INDEX_FILENAME = "accounts.json" + + +class AccountIndexEntry(TypedDict): + """Persisted local account metadata.""" + + username: str + last_used_at: str + session_path: str + cookiejar_path: str + + +def account_index_path(session_root: str | Path) -> Path: + """Return the JSON file path for the local account index.""" + + return Path(session_root) / ACCOUNT_INDEX_FILENAME + + +def load_accounts(session_root: str | Path) -> dict[str, AccountIndexEntry]: + """Load indexed accounts from disk.""" + + index_path = account_index_path(session_root) + try: + raw = json.loads(index_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError): + return {} + + accounts = raw.get("accounts") if isinstance(raw, dict) else None + if not isinstance(accounts, dict): + return {} + + normalized: dict[str, AccountIndexEntry] = {} + for username, entry in accounts.items(): + if not isinstance(username, str) or not isinstance(entry, dict): + continue + session_path = entry.get("session_path") + cookiejar_path = entry.get("cookiejar_path") + last_used_at = entry.get("last_used_at") + if not isinstance(session_path, str) or not isinstance(cookiejar_path, str): + continue + normalized[username] = { + "username": username, + "last_used_at": last_used_at if isinstance(last_used_at, str) else "", + "session_path": session_path, + "cookiejar_path": cookiejar_path, + } + return normalized + + +def _save_accounts( + session_root: str | Path, accounts: dict[str, AccountIndexEntry] +) -> None: + """Persist indexed accounts to disk.""" + + index_path = account_index_path(session_root) + if not accounts: + try: + index_path.unlink() + except FileNotFoundError: + pass + return + + index_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "accounts": {username: accounts[username] for username in sorted(accounts)} + } + index_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _is_discoverable( + entry: AccountIndexEntry, keyring_has: Callable[[str], bool] +) -> bool: + return ( + Path(entry["session_path"]).exists() + or Path(entry["cookiejar_path"]).exists() + or keyring_has(entry["username"]) + ) + + +def prune_accounts( + session_root: str | Path, keyring_has: Callable[[str], bool] +) -> list[AccountIndexEntry]: + """Drop stale entries and return discoverable accounts.""" + + accounts = load_accounts(session_root) + retained = { + username: entry + for username, entry in accounts.items() + if _is_discoverable(entry, keyring_has) + } + if retained != accounts: + _save_accounts(session_root, retained) + return [retained[username] for username in sorted(retained)] + + +def remember_account( + session_root: str | Path, + *, + username: str, + session_path: str, + cookiejar_path: str, + keyring_has: Callable[[str], bool], +) -> AccountIndexEntry: + """Upsert one account entry and prune any stale neighbors.""" + + accounts = { + entry["username"]: entry for entry in prune_accounts(session_root, keyring_has) + } + entry: AccountIndexEntry = { + "username": username, + "last_used_at": datetime.now(tz=timezone.utc).isoformat(), + "session_path": session_path, + "cookiejar_path": cookiejar_path, + } + accounts[username] = entry + _save_accounts(session_root, accounts) + return entry diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index d19427b8..53f05499 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -5,6 +5,7 @@ import typer from pyicloud.cli.commands.account import app as account_app +from pyicloud.cli.commands.auth import app as auth_app from pyicloud.cli.commands.calendar import app as calendar_app from pyicloud.cli.commands.contacts import app as contacts_app from pyicloud.cli.commands.devices import app as devices_app @@ -23,6 +24,7 @@ ) app.add_typer(account_app, name="account") +app.add_typer(auth_app, name="auth") app.add_typer(devices_app, name="devices") app.add_typer(calendar_app, name="calendar") app.add_typer(contacts_app, name="contacts") @@ -36,7 +38,11 @@ @app.callback(invoke_without_command=True) def root( ctx: typer.Context, - username: str = typer.Option("", "--username", help="Apple ID username."), + username: str = typer.Option( + "", + "--username", + help="Apple ID username. Optional when a command can infer a single account context.", + ), password: str | None = typer.Option( None, "--password", diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py new file mode 100644 index 00000000..4100da92 --- /dev/null +++ b/pyicloud/cli/commands/auth.py @@ -0,0 +1,237 @@ +"""Authentication and session commands.""" + +from __future__ import annotations + +import typer + +from pyicloud.cli.context import CLIAbort, get_state +from pyicloud.cli.output import console_kv_table, console_table + +app = typer.Typer( + help="Manage authentication and sessions for the selected or inferred account." +) + + +def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: + payload: dict[str, object] = { + "account_name": api.account_name, + "has_keyring_password": state.has_keyring_password(api.account_name), + **state.auth_storage_info(api), + **status, + } + return payload + + +@app.command("status") +def auth_status(ctx: typer.Context) -> None: + """Show the current authentication and session status.""" + + state = get_state(ctx) + if not state.has_explicit_username: + active_probes = state.active_session_probes() + if not active_probes: + if state.json_output: + state.write_json({"authenticated": False, "accounts": []}) + return + state.console.print( + "You are not logged into any iCloud accounts. To log in, run: " + "icloud --username auth login" + ) + return + + payloads = [_auth_payload(state, api, status) for api, status in active_probes] + if state.json_output: + if len(payloads) == 1: + state.write_json(payloads[0]) + else: + state.write_json({"authenticated": True, "accounts": payloads}) + return + if len(payloads) == 1: + payload = payloads[0] + state.console.print( + console_kv_table( + "Auth Status", + [ + ("Account", payload["account_name"]), + ("Authenticated", payload["authenticated"]), + ("Trusted Session", payload["trusted_session"]), + ("Requires 2FA", payload["requires_2fa"]), + ("Requires 2SA", payload["requires_2sa"]), + ("Stored Password", payload["has_keyring_password"]), + ("Session File", payload["session_path"]), + ("Session File Exists", payload["has_session_file"]), + ("Cookie Jar", payload["cookiejar_path"]), + ("Cookie Jar Exists", payload["has_cookiejar_file"]), + ], + ) + ) + return + state.console.print( + console_table( + "Active iCloud Sessions", + [ + "Account", + "Trusted Session", + "Stored Password", + "Session File Exists", + "Cookie Jar Exists", + ], + [ + ( + payload["account_name"], + payload["trusted_session"], + payload["has_keyring_password"], + payload["has_session_file"], + payload["has_cookiejar_file"], + ) + for payload in payloads + ], + ) + ) + return + + api = state.get_probe_api() + status = api.get_auth_status() + if status["authenticated"]: + state.remember_account(api) + else: + state.prune_local_accounts() + payload = _auth_payload(state, api, status) + if state.json_output: + state.write_json(payload) + return + state.console.print( + console_kv_table( + "Auth Status", + [ + ("Account", payload["account_name"]), + ("Authenticated", payload["authenticated"]), + ("Trusted Session", payload["trusted_session"]), + ("Requires 2FA", payload["requires_2fa"]), + ("Requires 2SA", payload["requires_2sa"]), + ("Stored Password", payload["has_keyring_password"]), + ("Session File", payload["session_path"]), + ("Session File Exists", payload["has_session_file"]), + ("Cookie Jar", payload["cookiejar_path"]), + ("Cookie Jar Exists", payload["has_cookiejar_file"]), + ], + ) + ) + + +@app.command("login") +def auth_login(ctx: typer.Context) -> None: + """Authenticate and persist a usable session.""" + + state = get_state(ctx) + api = state.get_login_api() + payload = _auth_payload( + state, + api, + { + "authenticated": True, + "trusted_session": api.is_trusted_session, + "requires_2fa": api.requires_2fa, + "requires_2sa": api.requires_2sa, + }, + ) + if state.json_output: + state.write_json(payload) + return + state.console.print("Authenticated session is ready.") + state.console.print( + console_kv_table( + "Auth Session", + [ + ("Account", payload["account_name"]), + ("Trusted Session", payload["trusted_session"]), + ("Session File", payload["session_path"]), + ("Cookie Jar", payload["cookiejar_path"]), + ], + ) + ) + + +@app.command("logout") +def auth_logout( + ctx: typer.Context, + keep_trusted: bool = typer.Option( + False, + "--keep-trusted", + help="Preserve trusted-browser state for the next login.", + ), + all_sessions: bool = typer.Option( + False, + "--all-sessions", + help="Attempt to sign out all browser sessions.", + ), + remove_keyring: bool = typer.Option( + False, + "--remove-keyring", + help="Delete the stored password for the selected account after logout.", + ), +) -> None: + """Log out and clear local session persistence.""" + + state = get_state(ctx) + if state.has_explicit_username: + api = state.get_probe_api() + api.get_auth_status() + else: + active_probes = state.active_session_probes() + if not active_probes: + if state.json_output: + state.write_json({"authenticated": False, "accounts": []}) + return + state.console.print( + "You are not logged into any iCloud accounts. To log in, run: " + "icloud --username auth login" + ) + return + if len(active_probes) > 1: + accounts = "\n".join( + f" - {api.account_name}" for api, _status in active_probes + ) + raise CLIAbort( + "Multiple logged-in iCloud accounts were found; pass --username to choose one.\n" + f"{accounts}" + ) + api, _status = active_probes[0] + + state.remember_account(api) + try: + result = api.logout( + keep_trusted=keep_trusted, + all_sessions=all_sessions, + clear_local_session=True, + ) + except OSError as exc: + raise CLIAbort("Failed to clear local session state.") from exc + + keyring_removed = False + if remove_keyring: + keyring_removed = state.delete_keyring_password(api.account_name) + state.prune_local_accounts() + + payload = { + "account_name": api.account_name, + "session_path": api.session.session_path, + "cookiejar_path": api.session.cookiejar_path, + "stored_password_removed": keyring_removed, + **result, + } + if state.json_output: + state.write_json(payload) + return + if payload["remote_logout_confirmed"] and keyring_removed: + state.console.print( + "Logged out, cleared local session, and removed stored password." + ) + elif payload["remote_logout_confirmed"]: + state.console.print("Logged out and cleared local session.") + elif keyring_removed: + state.console.print( + "Cleared local session, removed stored password; remote logout was not confirmed." + ) + else: + state.console.print("Cleared local session; remote logout was not confirmed.") diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index f64dd964..5f7231d9 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -14,9 +14,11 @@ from rich.console import Console from pyicloud import PyiCloudService, utils +from pyicloud.base import resolve_cookie_directory from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable from pyicloud.ssl_context import configurable_ssl_verification +from .account_index import AccountIndexEntry, prune_accounts, remember_account from .output import OutputFormat, write_json @@ -81,6 +83,14 @@ def __init__( self.err_console = Console(stderr=True) self._stack = ExitStack() self._api: Optional[PyiCloudService] = None + self._probe_api: Optional[PyiCloudService] = None + self._resolved_username: Optional[str] = self.username or None + + @property + def has_explicit_username(self) -> bool: + """Return whether the user explicitly passed --username.""" + + return bool(self.username) @property def json_output(self) -> bool: @@ -114,15 +124,94 @@ def delete_stored_password(self) -> bool: if not self.username: raise CLIAbort("A username is required with --delete-from-keyring.") - if utils.password_exists_in_keyring(self.username): - utils.delete_password_in_keyring(self.username) + return self.delete_keyring_password(self.username) + + def delete_keyring_password(self, username: str) -> bool: + """Delete a stored keyring password for a username.""" + + if utils.password_exists_in_keyring(username): + utils.delete_password_in_keyring(username) + self.prune_local_accounts() return True + self.prune_local_accounts() return False - def _password_for_login(self) -> Optional[str]: + def has_keyring_password(self, username: Optional[str] = None) -> bool: + """Return whether a keyring password exists for a username.""" + + candidate = (username or self._resolved_username or self.username).strip() + if not candidate: + return False + return utils.password_exists_in_keyring(candidate) + + @property + def session_root(self) -> Path: + """Return the resolved session root for this CLI invocation.""" + + return Path(resolve_cookie_directory(self.session_dir)) + + def local_accounts(self) -> list[AccountIndexEntry]: + """Return discoverable local accounts after opportunistic pruning.""" + + return prune_accounts(self.session_root, self.has_keyring_password) + + def prune_local_accounts(self) -> list[AccountIndexEntry]: + """Prune stale indexed accounts and return the discoverable set.""" + + return self.local_accounts() + + def remember_account(self, api: PyiCloudService, *, select: bool = True) -> None: + """Persist an account entry for later local discovery.""" + + remember_account( + self.session_root, + username=api.account_name, + session_path=api.session.session_path, + cookiejar_path=api.session.cookiejar_path, + keyring_has=self.has_keyring_password, + ) + if select: + self._resolved_username = api.account_name + + def _resolve_username(self) -> str: + if self._resolved_username: + return self._resolved_username + + accounts = self.local_accounts() + if not accounts: + raise CLIAbort( + "No local accounts were found; pass --username to bootstrap one." + ) + if len(accounts) > 1: + options = "\n".join(f" - {entry['username']}" for entry in accounts) + raise CLIAbort( + "Multiple local accounts were found; pass --username to choose one.\n" + f"{options}" + ) + + self._resolved_username = accounts[0]["username"] + return self._resolved_username + + def _not_logged_in_message(self) -> str: + """Return the default message for commands that require an active session.""" + + return ( + "You are not logged into any iCloud accounts. To log in, run: " + "icloud --username auth login" + ) + + def _not_logged_in_for_account_message(self, username: str) -> str: + """Return the message for account-targeted commands without an active session.""" + + return ( + f"You are not logged into iCloud for {username}. Run: " + f"icloud --username {username} auth login" + ) + + def _password_for_login(self, username: str) -> Optional[str]: if self.password: return self.password - return utils.get_password(self.username, interactive=self.interactive) + return utils.get_password(username, interactive=self.interactive) def _prompt_index(self, prompt: str, count: int) -> int: if count <= 1 or not self.interactive: @@ -185,17 +274,14 @@ def _handle_2sa(self, api: PyiCloudService) -> None: if not api.validate_verification_code(device, code): raise CLIAbort("Failed to verify the 2SA code.") - def get_api(self) -> PyiCloudService: - """Return an authenticated PyiCloudService instance.""" + def get_login_api(self) -> PyiCloudService: + """Return a PyiCloudService, bootstrapping login if needed.""" if self._api is not None: return self._api - if not self.username: - raise CLIAbort( - "The --username option is required for authenticated commands." - ) + username = self._resolve_username() - password = self._password_for_login() + password = self._password_for_login(username) if not password: raise CLIAbort("No password supplied and no stored password was found.") @@ -203,7 +289,7 @@ def get_api(self) -> PyiCloudService: try: api = PyiCloudService( - apple_id=self.username, + apple_id=username, password=password, china_mainland=self.china_mainland, cookie_directory=self.session_dir, @@ -211,16 +297,17 @@ def get_api(self) -> PyiCloudService: with_family=self.with_family, ) except PyiCloudFailedLoginException as err: - if utils.password_exists_in_keyring(self.username): - utils.delete_password_in_keyring(self.username) - raise CLIAbort(f"Bad username or password for {self.username}") from err + if utils.password_exists_in_keyring(username): + utils.delete_password_in_keyring(username) + self.prune_local_accounts() + raise CLIAbort(f"Bad username or password for {username}") from err if ( - not utils.password_exists_in_keyring(self.username) + not utils.password_exists_in_keyring(username) and self.interactive and confirm("Save password in keyring?") ): - utils.store_password_in_keyring(self.username, password) + utils.store_password_in_keyring(username, password) if api.requires_2fa: self._handle_2fa(api) @@ -228,8 +315,96 @@ def get_api(self) -> PyiCloudService: self._handle_2sa(api) self._api = api + self.remember_account(api) return api + def get_api(self) -> PyiCloudService: + """Return an authenticated PyiCloudService backed by an active session.""" + + if self._api is not None: + return self._api + + if self.has_explicit_username: + username = self._resolve_username() + api = self.build_probe_api(username) + status = api.get_auth_status() + if not status["authenticated"]: + raise CLIAbort(self._not_logged_in_for_account_message(username)) + self._api = api + self.remember_account(api) + return api + + active_probes = self.active_session_probes() + if not active_probes: + raise CLIAbort(self._not_logged_in_message()) + if len(active_probes) > 1: + accounts = "\n".join( + f" - {api.account_name}" for api, _status in active_probes + ) + raise CLIAbort( + "Multiple logged-in iCloud accounts were found; pass --username to choose one.\n" + f"{accounts}" + ) + + api, _status = active_probes[0] + self._api = api + self.remember_account(api) + return api + + def build_probe_api(self, username: str) -> PyiCloudService: + """Build a non-authenticating PyiCloudService for session probes.""" + + logging.basicConfig(level=self.log_level.logging_level()) + return PyiCloudService( + apple_id=username, + password=self.password, + china_mainland=self.china_mainland, + cookie_directory=self.session_dir, + accept_terms=self.accept_terms, + with_family=self.with_family, + authenticate=False, + ) + + def get_probe_api(self) -> PyiCloudService: + """Return a cached non-authenticating PyiCloudService for session probes.""" + + if self._probe_api is not None: + return self._probe_api + username = self._resolve_username() + self._probe_api = self.build_probe_api(username) + return self._probe_api + + def auth_storage_info( + self, api: Optional[PyiCloudService] = None + ) -> dict[str, Any]: + """Return session storage paths and presence flags.""" + + probe_api = api or self.get_probe_api() + session_path = Path(probe_api.session.session_path) + cookiejar_path = Path(probe_api.session.cookiejar_path) + return { + "session_path": str(session_path), + "cookiejar_path": str(cookiejar_path), + "has_session_file": session_path.exists(), + "has_cookiejar_file": cookiejar_path.exists(), + } + + def active_session_probes(self) -> list[tuple[PyiCloudService, dict[str, Any]]]: + """Return authenticated sessions discoverable from local session files.""" + + probes: list[tuple[PyiCloudService, dict[str, Any]]] = [] + for entry in self.local_accounts(): + session_path = Path(entry["session_path"]) + cookiejar_path = Path(entry["cookiejar_path"]) + if not (session_path.exists() or cookiejar_path.exists()): + continue + api = self.build_probe_api(entry["username"]) + status = api.get_auth_status() + if status["authenticated"]: + self.remember_account(api, select=False) + probes.append((api, status)) + return probes + def get_state(ctx: typer.Context) -> CLIState: """Return the shared CLI state for a command.""" diff --git a/pyicloud/session.py b/pyicloud/session.py index b3e34e7b..fe3d7a4c 100644 --- a/pyicloud/session.py +++ b/pyicloud/session.py @@ -111,6 +111,25 @@ def _save_session_data(self) -> None: except (OSError, ValueError) as exc: self.logger.warning("Failed to save cookies data: %s", exc) + def clear_persistence(self, remove_files: bool = True) -> None: + """Clear persisted session and cookie state.""" + + try: + cast(PyiCloudCookieJar, self.cookies).clear() + except (KeyError, RuntimeError): + pass + + self._data = {} + + if remove_files: + for persisted_path in (self.cookiejar_path, self.session_path): + try: + os.remove(persisted_path) + except FileNotFoundError: + continue + else: + self._save_session_data() + def _update_session_data(self, response: Response) -> None: """Update session_data with new data.""" for header, value in HEADER_DATA.items(): diff --git a/tests/test_base.py b/tests/test_base.py index 0d393c81..5638e875 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,6 +3,8 @@ # pylint: disable=protected-access +import json +import secrets from typing import Any, List from unittest.mock import MagicMock, mock_open, patch @@ -57,6 +59,51 @@ def test_authenticate_with_force_refresh(pyicloud_service: PyiCloudService) -> N validate_token.assert_called_once() +def test_constructor_accepts_positional_refresh_interval() -> None: + """refresh_interval stays positional-compatible with upstream.""" + with ( + patch("pyicloud.PyiCloudService.authenticate") as mock_authenticate, + patch("pyicloud.PyiCloudService._setup_cookie_directory") as mock_setup_dir, + patch("builtins.open", new_callable=mock_open), + ): + mock_authenticate.return_value = None + mock_setup_dir.return_value = "/tmp/pyicloud/cookies" + + service = PyiCloudService( + "test@example.com", + secrets.token_hex(32), + None, + True, + None, + True, + False, + False, + 30.0, + ) + + assert service._refresh_interval == 30.0 + + +def test_constructor_skips_authentication_when_requested() -> None: + """authenticate=False should not trigger login during construction.""" + with ( + patch("pyicloud.PyiCloudService.authenticate") as mock_authenticate, + patch("pyicloud.base.get_password_from_keyring") as get_from_keyring, + patch("pyicloud.PyiCloudService._setup_cookie_directory") as mock_setup_dir, + patch("builtins.open", new_callable=mock_open), + ): + mock_setup_dir.return_value = "/tmp/pyicloud/cookies" + + PyiCloudService( + "test@example.com", + secrets.token_hex(32), + authenticate=False, + ) + + mock_authenticate.assert_not_called() + get_from_keyring.assert_not_called() + + def test_authenticate_with_missing_token(pyicloud_service: PyiCloudService) -> None: """Test the authenticate method with missing session_token.""" with ( @@ -90,6 +137,84 @@ def test_authenticate_with_missing_token(pyicloud_service: PyiCloudService) -> N assert mock_authenticate_with_token.call_count == 2 +def test_get_auth_status_without_session_token( + pyicloud_service: PyiCloudService, +) -> None: + """Auth status should report unauthenticated when no token is present.""" + + pyicloud_service.session._data = {} + result = pyicloud_service.get_auth_status() + + assert result == { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + + +def test_get_auth_status_with_valid_session( + pyicloud_service: PyiCloudService, +) -> None: + """Auth status should validate a persisted session token without logging in.""" + + pyicloud_service.session._data = {"session_token": "token"} + pyicloud_service.session.cookies = MagicMock() + pyicloud_service.session.cookies.get.return_value = "cookie" + + with patch.object( + pyicloud_service, + "_validate_token", + return_value={ + "dsInfo": {"dsid": "123", "hsaVersion": 2}, + "hsaTrustedBrowser": True, + "webservices": {"findme": {"url": "https://example.com"}}, + }, + ): + result = pyicloud_service.get_auth_status() + + assert result == { + "authenticated": True, + "trusted_session": True, + "requires_2fa": False, + "requires_2sa": False, + } + assert pyicloud_service.params["dsid"] == "123" + + +def test_get_auth_status_invalid_token_does_not_fallback_to_login( + pyicloud_service: PyiCloudService, +) -> None: + """Auth status should not attempt a password-based login on invalid tokens.""" + + pyicloud_service.session._data = {"session_token": "token"} + pyicloud_service.session.cookies = MagicMock() + pyicloud_service.session.cookies.get.return_value = "cookie" + pyicloud_service.data = {"hsaTrustedBrowser": True} + pyicloud_service.params["dsid"] = "123" + pyicloud_service._devices = MagicMock() + + with ( + patch.object( + pyicloud_service, + "_validate_token", + side_effect=PyiCloudAPIResponseException("Invalid token"), + ), + patch.object(pyicloud_service, "_authenticate") as mock_authenticate, + ): + result = pyicloud_service.get_auth_status() + + assert result == { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + assert "dsid" not in pyicloud_service.params + assert pyicloud_service._devices is None + mock_authenticate.assert_not_called() + + def test_validate_2fa_code(pyicloud_service: PyiCloudService) -> None: """Test the validate_2fa_code method with a valid code.""" @@ -220,6 +345,71 @@ def test_trust_session_failure(pyicloud_service: PyiCloudService) -> None: assert not pyicloud_service.trust_session() +@pytest.mark.parametrize( + ("keep_trusted", "all_sessions", "expected_payload"), + [ + (False, False, {"trustBrowser": False, "allBrowsers": False}), + (True, False, {"trustBrowser": True, "allBrowsers": False}), + (False, True, {"trustBrowser": False, "allBrowsers": True}), + (True, True, {"trustBrowser": True, "allBrowsers": True}), + ], +) +def test_logout_payload_mappings( + pyicloud_service: PyiCloudService, + keep_trusted: bool, + all_sessions: bool, + expected_payload: dict[str, bool], +) -> None: + """Logout should map CLI semantics to Apple's payload exactly.""" + + pyicloud_service.params["dsid"] = "123" + pyicloud_service.session.cookies = MagicMock() + pyicloud_service.session.cookies.get.return_value = "cookie" + pyicloud_service.session.clear_persistence = MagicMock() + pyicloud_service.session.post = MagicMock( + return_value=MagicMock(json=MagicMock(return_value={"success": True})) + ) + + result = pyicloud_service.logout( + keep_trusted=keep_trusted, + all_sessions=all_sessions, + ) + + kwargs = pyicloud_service.session.post.call_args.kwargs + assert kwargs["params"]["dsid"] == "123" + assert kwargs["headers"] == {"Content-Type": "text/plain;charset=UTF-8"} + assert json.loads(kwargs["data"]) == expected_payload + assert result["payload"] == expected_payload + assert result["remote_logout_confirmed"] is True + + +def test_logout_clears_authenticated_state( + pyicloud_service: PyiCloudService, +) -> None: + """Logout should clear in-memory auth state and persisted session data.""" + + pyicloud_service.data = {"dsInfo": {"dsid": "123"}} + pyicloud_service.params["dsid"] = "123" + pyicloud_service._devices = MagicMock() + pyicloud_service.session.cookies = MagicMock() + pyicloud_service.session.cookies.get.return_value = "cookie" + pyicloud_service.session.post = MagicMock( + side_effect=PyiCloudAPIResponseException("logout failed") + ) + pyicloud_service.session.clear_persistence = MagicMock() + + result = pyicloud_service.logout() + + assert result["remote_logout_confirmed"] is False + assert result["local_session_cleared"] is True + pyicloud_service.session.clear_persistence.assert_called_once_with( + remove_files=True + ) + assert pyicloud_service.data == {} + assert "dsid" not in pyicloud_service.params + assert pyicloud_service._devices is None + + def test_cookiejar_path_property(pyicloud_session: PyiCloudSession) -> None: """Test the cookiejar_path property.""" path: str = pyicloud_session.cookiejar_path @@ -232,6 +422,25 @@ def test_session_path_property(pyicloud_session: PyiCloudSession) -> None: assert isinstance(path, str) +def test_clear_persistence_removes_session_and_cookie_files( + pyicloud_session: PyiCloudSession, +) -> None: + """Session persistence cleanup should clear cookies and remove persisted files.""" + + pyicloud_session._data = {"session_token": "token"} + with patch("pyicloud.session.os.remove") as mock_remove: + pyicloud_session.clear_persistence() + + pyicloud_session.cookies.clear.assert_called_once_with() + assert pyicloud_session.data == {} + assert mock_remove.call_count == 2 + removed_paths = {call.args[0] for call in mock_remove.call_args_list} + assert removed_paths == { + pyicloud_session.cookiejar_path, + pyicloud_session.session_path, + } + + def test_requires_2sa_property(pyicloud_service: PyiCloudService) -> None: """Test the requires_2sa property.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 2}} diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index a7cebb9d..2054a7f3 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -11,13 +11,17 @@ from types import SimpleNamespace from typing import Any, Iterable, Optional from unittest.mock import MagicMock, patch +from uuid import uuid4 from typer.testing import CliRunner +account_index_module = importlib.import_module("pyicloud.cli.account_index") cli_module = importlib.import_module("pyicloud.cli.app") context_module = importlib.import_module("pyicloud.cli.context") app = cli_module.app +TEST_ROOT = Path("/tmp/python-test-results/test_cmdline") + class FakeDevice: """Find My device fixture.""" @@ -351,7 +355,12 @@ def iter_changes(self, since: Optional[str] = None): class FakeAPI: """Authenticated API fixture.""" - def __init__(self) -> None: + def __init__( + self, + *, + username: str = "user@example.com", + session_dir: Optional[Path] = None, + ) -> None: self.requires_2fa = False self.requires_2sa = False self.is_trusted_session = True @@ -362,7 +371,24 @@ def __init__(self) -> None: self.send_verification_code = MagicMock(return_value=True) self.validate_verification_code = MagicMock(return_value=True) self.trust_session = MagicMock(return_value=True) - self.account_name = "user@example.com" + self.account_name = username + session_dir = session_dir or _unique_session_dir("fake-api") + session_stub = "".join( + character for character in username if character.isalnum() + ) + self.session = SimpleNamespace( + session_path=str(session_dir / f"{session_stub}.session"), + cookiejar_path=str(session_dir / f"{session_stub}.cookiejar"), + ) + self.get_auth_status = MagicMock( + return_value={ + "authenticated": True, + "trusted_session": True, + "requires_2fa": False, + "requires_2sa": False, + } + ) + self.logout = MagicMock(side_effect=self._logout) self.devices = [FakeDevice()] self.account = SimpleNamespace( devices=[ @@ -451,22 +477,86 @@ def __init__(self) -> None: self.reminders = FakeReminders() self.notes = FakeNotes() + def _logout( + self, + *, + keep_trusted: bool = False, + all_sessions: bool = False, + clear_local_session: bool = True, + ) -> dict[str, Any]: + if clear_local_session: + for path in (self.session.session_path, self.session.cookiejar_path): + try: + Path(path).unlink() + except FileNotFoundError: + pass + self.get_auth_status.return_value = { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + return { + "payload": { + "trustBrowser": keep_trusted, + "allBrowsers": all_sessions, + }, + "remote_logout_confirmed": True, + "local_session_cleared": clear_local_session, + } + def _runner() -> CliRunner: return CliRunner() +def _unique_session_dir(label: str = "session") -> Path: + path = TEST_ROOT / f"{label}-{uuid4().hex}" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _remember_local_account( + session_dir: Path, + username: str, + *, + has_session_file: bool = False, + has_cookiejar_file: bool = False, + keyring_passwords: Optional[set[str]] = None, +) -> FakeAPI: + fake_api = FakeAPI(username=username, session_dir=session_dir) + if has_session_file: + with open(fake_api.session.session_path, "w", encoding="utf-8"): + pass + if has_cookiejar_file: + with open(fake_api.session.cookiejar_path, "w", encoding="utf-8"): + pass + account_index_module.remember_account( + session_dir, + username=username, + session_path=fake_api.session.session_path, + cookiejar_path=fake_api.session.cookiejar_path, + keyring_has=lambda candidate: candidate in (keyring_passwords or set()), + ) + return fake_api + + def _invoke( fake_api: FakeAPI, *args: str, + username: Optional[str] = "user@example.com", + password: Optional[str] = "secret", interactive: bool = False, + session_dir: Optional[Path] = None, + keyring_passwords: Optional[set[str]] = None, ): runner = _runner() + session_dir = session_dir or _unique_session_dir("invoke") cli_args = [ - "--username", - "user@example.com", - "--password", - "secret", + *([] if username is None else ["--username", username]), + *([] if password is None else ["--password", password]), + "--session-dir", + str(session_dir), *([] if interactive else ["--non-interactive"]), *args, ] @@ -477,7 +567,9 @@ def _invoke( ), patch.object(context_module, "confirm", return_value=False), patch.object( - context_module.utils, "password_exists_in_keyring", return_value=False + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: candidate in (keyring_passwords or set()), ), ): return runner.invoke(app, cli_args) @@ -488,11 +580,14 @@ def test_root_help() -> None: result = _runner().invoke(app, ["--help"]) assert result.exit_code == 0 + assert "--username" in result.stdout + assert "Optional when a" in result.stdout assert "--format" in result.stdout assert "--json" not in result.stdout assert "--debug" not in result.stdout for command in ( "account", + "auth", "devices", "calendar", "contacts", @@ -510,6 +605,7 @@ def test_group_help() -> None: for command in ( "account", + "auth", "devices", "calendar", "contacts", @@ -551,59 +647,555 @@ def test_default_log_level_is_warning() -> None: basic_config.assert_called_once_with(level=context_module.logging.WARNING) -def test_missing_username_errors_cleanly() -> None: - """Authenticated commands should fail without a traceback when username is missing.""" +def test_no_local_accounts_require_username() -> None: + """Authenticated service commands should require a logged-in session.""" + session_dir = _unique_session_dir("no-local-accounts") with patch.object( context_module, "configurable_ssl_verification", return_value=nullcontext() ): - result = _runner().invoke(app, ["account", "summary"]) + result = _runner().invoke( + app, ["--session-dir", str(session_dir), "account", "summary"] + ) assert result.exit_code != 0 - assert "The --username option is required" in result.exception.args[0] + assert ( + result.exception.args[0] + == "You are not logged into any iCloud accounts. To log in, run: " + "icloud --username auth login" + ) def test_delete_from_keyring() -> None: """The keyring delete path should work without invoking a subcommand.""" + session_dir = _unique_session_dir("delete-keyring") + _remember_local_account( + session_dir, + "user@example.com", + keyring_passwords={"user@example.com"}, + ) with ( patch.object( context_module, "configurable_ssl_verification", return_value=nullcontext() ), - patch.object( - context_module.utils, "password_exists_in_keyring", return_value=True - ), patch.object( context_module.utils, "delete_password_in_keyring" ) as delete_password, + ): + with patch.object( + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: not delete_password.called, + ): + result = _runner().invoke( + app, + [ + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + "--delete-from-keyring", + ], + ) + assert result.exit_code == 0 + delete_password.assert_called_once_with("user@example.com") + assert "Deleted stored password from keyring." in result.stdout + assert account_index_module.load_accounts(session_dir) == {} + + +def test_auth_status_probe_is_non_interactive() -> None: + """Auth status should probe persisted sessions without prompting for login.""" + + session_dir = _unique_session_dir("auth-status") + fake_api = _remember_local_account( + session_dir, + "user@example.com", + has_session_file=True, + ) + fake_api.get_auth_status.return_value = { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + with ( + patch.object(context_module, "PyiCloudService", return_value=fake_api), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + patch.object(context_module.utils, "get_password", side_effect=AssertionError), + patch.object(context_module.typer, "prompt", side_effect=AssertionError), + ): + result = _runner().invoke( + app, + ["--session-dir", str(session_dir), "--non-interactive", "auth", "status"], + ) + assert result.exit_code == 0 + assert "You are not logged into any iCloud accounts." in result.stdout + + +def test_auth_status_without_username_ignores_keyring_only_accounts() -> None: + """Implicit auth status should report active sessions, not stored credentials.""" + + session_dir = _unique_session_dir("status-keyring-only") + _remember_local_account( + session_dir, + "user@example.com", + keyring_passwords={"user@example.com"}, + ) + + result = _invoke( + FakeAPI(username="user@example.com", session_dir=session_dir), + "auth", + "status", + username=None, + session_dir=session_dir, + keyring_passwords={"user@example.com"}, + ) + + assert result.exit_code == 0 + assert "You are not logged into any iCloud accounts." in result.stdout + assert "user@example.com" not in result.stdout + + +def test_auth_login_and_status_commands() -> None: + """Auth status and login should expose stable text and JSON payloads.""" + + fake_api = FakeAPI() + status_result = _invoke(fake_api, "--format", "json", "auth", "status") + login_result = _invoke(fake_api, "--format", "json", "auth", "login") + + status_payload = json.loads(status_result.stdout) + login_payload = json.loads(login_result.stdout) + + assert status_result.exit_code == 0 + assert status_payload["authenticated"] is True + assert status_payload["trusted_session"] is True + assert status_payload["account_name"] == "user@example.com" + assert login_result.exit_code == 0 + assert login_payload["authenticated"] is True + assert login_payload["session_path"] == fake_api.session.session_path + + +def test_single_known_account_supports_implicit_local_context() -> None: + """Implicit local context should work only while an active session exists.""" + + session_dir = _unique_session_dir("implicit-context") + _remember_local_account( + session_dir, + "solo@example.com", + has_session_file=True, + keyring_passwords={"solo@example.com"}, + ) + + status_result = _invoke( + FakeAPI(username="solo@example.com", session_dir=session_dir), + "auth", + "status", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + account_result = _invoke( + FakeAPI(username="solo@example.com", session_dir=session_dir), + "account", + "summary", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + devices_result = _invoke( + FakeAPI(username="solo@example.com", session_dir=session_dir), + "devices", + "list", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + logout_api = FakeAPI(username="solo@example.com", session_dir=session_dir) + logout_result = _invoke( + logout_api, + "auth", + "logout", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + post_logout_account_result = _invoke( + logout_api, + "account", + "summary", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + post_logout_explicit_result = _invoke( + logout_api, + "account", + "summary", + username="solo@example.com", + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + login_result = _invoke( + FakeAPI(username="solo@example.com", session_dir=session_dir), + "auth", + "login", + username=None, + session_dir=session_dir, + keyring_passwords={"solo@example.com"}, + ) + + assert status_result.exit_code == 0 + assert "solo@example.com" in status_result.stdout + assert account_result.exit_code == 0 + assert devices_result.exit_code == 0 + assert logout_result.exit_code == 0 + assert post_logout_account_result.exit_code != 0 + assert ( + post_logout_account_result.exception.args[0] + == "You are not logged into any iCloud accounts. To log in, run: " + "icloud --username auth login" + ) + assert post_logout_explicit_result.exit_code != 0 + assert ( + post_logout_explicit_result.exception.args[0] + == "You are not logged into iCloud for solo@example.com. Run: " + "icloud --username solo@example.com auth login" + ) + assert login_result.exit_code == 0 + assert [ + entry["username"] + for entry in account_index_module.prune_accounts( + session_dir, lambda candidate: candidate == "solo@example.com" + ) + ] == ["solo@example.com"] + + +def test_multiple_local_accounts_require_explicit_username_for_auth_login() -> None: + """Auth login should list local accounts when bootstrap discovery is ambiguous.""" + + session_dir = _unique_session_dir("multiple-contexts") + _remember_local_account( + session_dir, + "alpha@example.com", + keyring_passwords={"alpha@example.com", "beta@example.com"}, + ) + _remember_local_account( + session_dir, + "beta@example.com", + keyring_passwords={"alpha@example.com", "beta@example.com"}, + ) + + with ( + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: candidate + in {"alpha@example.com", "beta@example.com"}, + ), + ): + result = _runner().invoke( + app, + [ + "--session-dir", + str(session_dir), + "--non-interactive", + "auth", + "login", + ], + ) + + assert result.exit_code != 0 + assert "Multiple local accounts were found" in result.exception.args[0] + assert "alpha@example.com" in result.exception.args[0] + assert "beta@example.com" in result.exception.args[0] + + +def test_multiple_active_sessions_require_explicit_username() -> None: + """Service commands should not guess when multiple active sessions exist.""" + + session_dir = _unique_session_dir("multiple-active-sessions") + alpha_api = _remember_local_account( + session_dir, + "alpha@example.com", + has_session_file=True, + ) + beta_api = _remember_local_account( + session_dir, + "beta@example.com", + has_session_file=True, + ) + apis = { + "alpha@example.com": alpha_api, + "beta@example.com": beta_api, + } + + def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: + return apis[apple_id] + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), ): result = _runner().invoke( app, - ["--username", "user@example.com", "--delete-from-keyring"], + [ + "--session-dir", + str(session_dir), + "--non-interactive", + "account", + "summary", + ], ) + + assert result.exit_code != 0 + assert "Multiple logged-in iCloud accounts were found" in result.exception.args[0] + assert "alpha@example.com" in result.exception.args[0] + assert "beta@example.com" in result.exception.args[0] + + +def test_explicit_username_overrides_ambiguous_local_context() -> None: + """Explicit usernames should continue to work when multiple local accounts exist.""" + + session_dir = _unique_session_dir("explicit-override") + _remember_local_account( + session_dir, + "alpha@example.com", + keyring_passwords={"alpha@example.com", "beta@example.com"}, + ) + _remember_local_account( + session_dir, + "beta@example.com", + keyring_passwords={"alpha@example.com", "beta@example.com"}, + ) + + result = _invoke( + FakeAPI(username="beta@example.com", session_dir=session_dir), + "account", + "summary", + username="beta@example.com", + session_dir=session_dir, + keyring_passwords={"alpha@example.com", "beta@example.com"}, + ) + assert result.exit_code == 0 + assert "beta@example.com" in result.stdout + + +def test_authenticated_commands_update_account_index() -> None: + """Successful authenticated commands should index the resolved account.""" + + session_dir = _unique_session_dir("index-update") + fake_api = FakeAPI(username="indexed@example.com", session_dir=session_dir) + + result = _invoke( + fake_api, + "account", + "summary", + username="indexed@example.com", + session_dir=session_dir, + ) + + indexed_accounts = account_index_module.load_accounts(session_dir) + + assert result.exit_code == 0 + assert "indexed@example.com" in indexed_accounts + assert indexed_accounts["indexed@example.com"]["session_path"] == ( + fake_api.session.session_path + ) + + +def test_account_index_prunes_stale_entries_but_keeps_keyring_backed_accounts() -> None: + """Local account discovery should prune stale entries and retain keyring-backed ones.""" + + session_dir = _unique_session_dir("index-prune") + stale_api = _remember_local_account( + session_dir, + "stale@example.com", + has_session_file=True, + ) + Path(stale_api.session.session_path).unlink() + kept_api = _remember_local_account( + session_dir, + "kept@example.com", + keyring_passwords={"kept@example.com"}, + ) + + discovered = account_index_module.prune_accounts( + session_dir, + lambda candidate: candidate == "kept@example.com", + ) + + assert [entry["username"] for entry in discovered] == ["kept@example.com"] + assert list(account_index_module.load_accounts(session_dir)) == ["kept@example.com"] + assert kept_api.session.session_path.endswith("keptexamplecom.session") + + +def test_auth_login_non_interactive_requires_credentials() -> None: + """Auth login should fail cleanly when non-interactive mode lacks credentials.""" + + with ( + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + patch.object(context_module.utils, "get_password", return_value=None), + ): + result = _runner().invoke( + app, + ["--username", "user@example.com", "--non-interactive", "auth", "login"], + ) + assert result.exit_code != 0 + assert "No password supplied and no stored password was found." in str( + result.exception + ) + + +def test_auth_logout_variants_and_remote_failure() -> None: + """Auth logout should map semantic flags to Apple's payload and keep keyring intact.""" + + def invoke_logout(*args: str, failing_api: Optional[FakeAPI] = None): + session_dir = _unique_session_dir("auth-logout") + _remember_local_account( + session_dir, + "user@example.com", + has_session_file=True, + keyring_passwords={"user@example.com"}, + ) + return _invoke( + failing_api or FakeAPI(session_dir=session_dir), + "--format", + "json", + "auth", + "logout", + *args, + username=None, + session_dir=session_dir, + keyring_passwords={"user@example.com"}, + ) + + default_result = invoke_logout() + keep_trusted_result = invoke_logout("--keep-trusted") + all_sessions_result = invoke_logout("--all-sessions") + combined_result = invoke_logout("--keep-trusted", "--all-sessions") + + assert default_result.exit_code == 0 + assert json.loads(default_result.stdout)["payload"] == { + "trustBrowser": False, + "allBrowsers": False, + } + assert keep_trusted_result.exit_code == 0 + assert json.loads(keep_trusted_result.stdout)["payload"] == { + "trustBrowser": True, + "allBrowsers": False, + } + assert all_sessions_result.exit_code == 0 + assert json.loads(all_sessions_result.stdout)["payload"] == { + "trustBrowser": False, + "allBrowsers": True, + } + assert combined_result.exit_code == 0 + assert json.loads(combined_result.stdout)["payload"] == { + "trustBrowser": True, + "allBrowsers": True, + } + + session_dir = _unique_session_dir("auth-logout-failure") + _remember_local_account( + session_dir, + "user@example.com", + has_session_file=True, + keyring_passwords={"user@example.com"}, + ) + failing_api = FakeAPI(session_dir=session_dir) + failing_api.logout = MagicMock( + return_value={ + "payload": {"trustBrowser": False, "allBrowsers": False}, + "remote_logout_confirmed": False, + "local_session_cleared": True, + } + ) + with patch.object( + context_module.utils, "delete_password_in_keyring" + ) as delete_password: + failure_result = _invoke( + failing_api, + "auth", + "logout", + username=None, + session_dir=session_dir, + keyring_passwords={"user@example.com"}, + ) + assert failure_result.exit_code == 0 + assert "remote logout was not confirmed" in failure_result.stdout + delete_password.assert_not_called() + + +def test_auth_logout_remove_keyring_is_explicit() -> None: + """Auth logout should only delete stored passwords when requested.""" + + session_dir = _unique_session_dir("auth-logout-remove-keyring") + _remember_local_account( + session_dir, + "user@example.com", + has_session_file=True, + keyring_passwords={"user@example.com"}, + ) + + with patch.object( + context_module.utils, "delete_password_in_keyring" + ) as delete_password: + result = _invoke( + FakeAPI(session_dir=session_dir), + "--format", + "json", + "auth", + "logout", + "--remove-keyring", + username=None, + session_dir=session_dir, + keyring_passwords={"user@example.com"}, + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 0 + assert payload["stored_password_removed"] is True delete_password.assert_called_once_with("user@example.com") - assert "Deleted stored password from keyring." in result.stdout def test_security_key_flow() -> None: - """Security-key 2FA should confirm the selected key.""" + """Auth login should confirm the selected security key.""" fake_api = FakeAPI() fake_api.requires_2fa = True fake_api.fido2_devices = [{"id": "sk-1"}] - result = _invoke(fake_api, "account", "summary") + result = _invoke(fake_api, "auth", "login") assert result.exit_code == 0 fake_api.confirm_security_key.assert_called_once_with({"id": "sk-1"}) def test_trusted_device_2sa_flow() -> None: - """2SA should send and validate a verification code.""" + """Auth login should send and validate a 2SA verification code.""" fake_api = FakeAPI() fake_api.requires_2sa = True fake_api.trusted_devices = [{"deviceName": "Trusted Device", "phoneNumber": "+1"}] with patch.object(context_module.typer, "prompt", return_value="123456"): - result = _invoke(fake_api, "account", "summary", interactive=True) + result = _invoke(fake_api, "auth", "login", interactive=True) assert result.exit_code == 0 fake_api.send_verification_code.assert_called_once_with(fake_api.trusted_devices[0]) fake_api.validate_verification_code.assert_called_once_with( @@ -811,7 +1403,7 @@ def test_notes_commands() -> None: def test_main_returns_clean_error_for_user_abort(capsys) -> None: """The entrypoint should not emit a traceback for expected CLI errors.""" - message = "The --username option is required for authenticated commands." + message = "No local accounts were found; pass --username to bootstrap one." with patch.object(cli_module, "app", side_effect=context_module.CLIAbort(message)): code = cli_module.main() captured = capsys.readouterr() From 784bfe90619ca23e0f26db82a6cab8d6fed26aba Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Tue, 17 Mar 2026 23:10:17 +0100 Subject: [PATCH 04/18] Support final-command CLI options --- README.md | 14 +- pyicloud/cli/app.py | 30 ++-- pyicloud/cli/commands/account.py | 5 + pyicloud/cli/commands/auth.py | 4 + pyicloud/cli/commands/calendar.py | 3 + pyicloud/cli/commands/contacts.py | 3 + pyicloud/cli/commands/devices.py | 8 ++ pyicloud/cli/commands/drive.py | 3 + pyicloud/cli/commands/hidemyemail.py | 8 ++ pyicloud/cli/commands/notes.py | 8 ++ pyicloud/cli/commands/photos.py | 4 + pyicloud/cli/commands/reminders.py | 8 ++ pyicloud/cli/context.py | 129 ++++++++++++++++- pyicloud/cli/options.py | 186 ++++++++++++++++++++++++ tests/test_cmdline.py | 204 ++++++++++++++++++++++++++- 15 files changed, 601 insertions(+), 16 deletions(-) create mode 100644 pyicloud/cli/options.py diff --git a/README.md b/README.md index 6fe45604..8c0640bb 100644 --- a/README.md +++ b/README.md @@ -106,17 +106,20 @@ Examples: ```console $ icloud auth status $ icloud auth login +$ icloud auth login --username jappleseed@apple.com $ icloud auth logout $ icloud auth logout --keep-trusted $ icloud auth logout --all-sessions $ icloud auth logout --keep-trusted --all-sessions $ icloud auth logout --remove-keyring $ icloud account summary +$ icloud account summary --format json $ icloud devices list --locate +$ icloud devices list --session-dir /tmp/pyicloud-test --format json $ icloud devices show "Jacob's iPhone" $ icloud devices export "Jacob's iPhone" --output ./iphone.json $ icloud --username=jappleseed@apple.com auth login -$ icloud --username=jappleseed@apple.com account summary +$ icloud --format json account summary $ icloud --username=jappleseed@apple.com calendar events --period week $ icloud --username=jappleseed@apple.com contacts me $ icloud --username=jappleseed@apple.com drive list /Documents @@ -149,6 +152,12 @@ The `auth` command group lets you inspect and manage persisted sessions: - `icloud auth logout --remove-keyring`: also delete the stored password for the selected account - `icloud auth logout --keep-trusted --all-sessions`: experimental combination that requests both behaviors +Execution-context options such as `--username`, `--password`, +`--session-dir`, and `--format` can be provided either before the +command or on the final command. The preferred style is to place them on +the final command, for example `icloud account summary --format json` or +`icloud auth login --username=`. + When only one local account is known, `auth login` can omit `--username`. Service commands, `auth status`, and `auth logout` without `--username` operate on active logged-in sessions only, similar to `gh`. @@ -166,7 +175,8 @@ Stored passwords in the system keyring are treated separately from authenticated sessions. A plain `icloud auth logout` ends the session but keeps the stored password. Use `icloud auth logout --remove-keyring` or `icloud --username= --delete-from-keyring` if you also want -to forget the saved password. +to forget the saved password. The utility flag `--delete-from-keyring` +remains root-only. Migration notes for the previous Find My-focused CLI: diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index 53f05499..28ee58b5 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -14,11 +14,14 @@ from pyicloud.cli.commands.notes import app as notes_app from pyicloud.cli.commands.photos import app as photos_app from pyicloud.cli.commands.reminders import app as reminders_app -from pyicloud.cli.context import CLIAbort, CLIState, LogLevel +from pyicloud.cli.context import CLIAbort, CLIInvocationDefaults, CLIState, LogLevel from pyicloud.cli.output import OutputFormat app = typer.Typer( - help="Command line interface for pyicloud services.", + help=( + "Command line interface for pyicloud services. Execution-context options " + "may be provided either before the command or on the final command." + ), no_args_is_help=True, pretty_exceptions_show_locals=False, ) @@ -41,7 +44,10 @@ def root( username: str = typer.Option( "", "--username", - help="Apple ID username. Optional when a command can infer a single account context.", + help=( + "Apple ID username. Can be provided before the command or on the final " + "command. Optional when a command can infer a single account context." + ), ), password: str | None = typer.Option( None, @@ -95,12 +101,15 @@ def root( OutputFormat.TEXT, "--format", case_sensitive=False, - help="Output format for command results.", + help=( + "Output format for command results. Can be provided before the command " + "or on the final command." + ), ), ) -> None: """Initialize shared CLI state.""" - state = CLIState( + defaults = CLIInvocationDefaults( username=username, password=password, china_mainland=china_mainland, @@ -115,12 +124,15 @@ def root( log_level=log_level, output_format=output_format, ) - state.open() - ctx.call_on_close(state.close) - ctx.obj = state + ctx.obj = defaults if delete_from_keyring: - deleted = state.delete_stored_password() + state = CLIState.from_invocation(defaults) + state.open() + try: + deleted = state.delete_stored_password() + finally: + state.close() if ctx.invoked_subcommand is None: state.console.print( "Deleted stored password from keyring." diff --git a/pyicloud/cli/commands/account.py b/pyicloud/cli/commands/account.py index acb746c9..ad99cb51 100644 --- a/pyicloud/cli/commands/account.py +++ b/pyicloud/cli/commands/account.py @@ -11,12 +11,14 @@ normalize_family_member, normalize_storage, ) +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud account metadata.") @app.command("summary") +@with_execution_context_options def account_summary(ctx: typer.Context) -> None: """Show high-level account information.""" @@ -37,6 +39,7 @@ def account_summary(ctx: typer.Context) -> None: @app.command("devices") +@with_execution_context_options def account_devices(ctx: typer.Context) -> None: """List devices associated with the account profile.""" @@ -67,6 +70,7 @@ def account_devices(ctx: typer.Context) -> None: @app.command("family") +@with_execution_context_options def account_family(ctx: typer.Context) -> None: """List family sharing members.""" @@ -97,6 +101,7 @@ def account_family(ctx: typer.Context) -> None: @app.command("storage") +@with_execution_context_options def account_storage(ctx: typer.Context) -> None: """Show iCloud storage usage.""" diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index 4100da92..8898a893 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -5,6 +5,7 @@ import typer from pyicloud.cli.context import CLIAbort, get_state +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_kv_table, console_table app = typer.Typer( @@ -23,6 +24,7 @@ def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: @app.command("status") +@with_execution_context_options def auth_status(ctx: typer.Context) -> None: """Show the current authentication and session status.""" @@ -120,6 +122,7 @@ def auth_status(ctx: typer.Context) -> None: @app.command("login") +@with_execution_context_options def auth_login(ctx: typer.Context) -> None: """Authenticate and persist a usable session.""" @@ -153,6 +156,7 @@ def auth_login(ctx: typer.Context) -> None: @app.command("logout") +@with_execution_context_options def auth_logout( ctx: typer.Context, keep_trusted: bool = typer.Option( diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py index cd1e0a5f..8738fb7b 100644 --- a/pyicloud/cli/commands/calendar.py +++ b/pyicloud/cli/commands/calendar.py @@ -9,12 +9,14 @@ from pyicloud.cli.context import get_state, parse_datetime, service_call from pyicloud.cli.normalize import normalize_calendar, normalize_event +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect calendars and events.") @app.command("calendars") +@with_execution_context_options def calendar_calendars(ctx: typer.Context) -> None: """List available calendars.""" @@ -45,6 +47,7 @@ def calendar_calendars(ctx: typer.Context) -> None: @app.command("events") +@with_execution_context_options def calendar_events( ctx: typer.Context, from_dt: Optional[str] = typer.Option(None, "--from", help="Start datetime."), diff --git a/pyicloud/cli/commands/contacts.py b/pyicloud/cli/commands/contacts.py index 9d014a7f..0c05d816 100644 --- a/pyicloud/cli/commands/contacts.py +++ b/pyicloud/cli/commands/contacts.py @@ -8,12 +8,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_contact, normalize_me +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud contacts.") @app.command("list") +@with_execution_context_options def contacts_list( ctx: typer.Context, limit: int = typer.Option(50, "--limit", min=1, help="Maximum contacts to show."), @@ -50,6 +52,7 @@ def contacts_list( @app.command("me") +@with_execution_context_options def contacts_me(ctx: typer.Context) -> None: """Show the signed-in contact card.""" diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 31276283..c28de105 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -8,6 +8,7 @@ from pyicloud.cli.context import get_state, resolve_device, service_call from pyicloud.cli.normalize import normalize_device_details, normalize_device_summary +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import ( console_kv_table, console_table, @@ -19,6 +20,7 @@ @app.command("list") +@with_execution_context_options def devices_list( ctx: typer.Context, locate: bool = typer.Option( @@ -56,6 +58,7 @@ def devices_list( @app.command("show") +@with_execution_context_options def devices_show( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -93,6 +96,7 @@ def devices_show( @app.command("sound") +@with_execution_context_options def devices_sound( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -112,6 +116,7 @@ def devices_sound( @app.command("message") +@with_execution_context_options def devices_message( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -138,6 +143,7 @@ def devices_message( @app.command("lost-mode") +@with_execution_context_options def devices_lost_mode( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -168,6 +174,7 @@ def devices_lost_mode( @app.command("erase") +@with_execution_context_options def devices_erase( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -190,6 +197,7 @@ def devices_erase( @app.command("export") +@with_execution_context_options def devices_export( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), diff --git a/pyicloud/cli/commands/drive.py b/pyicloud/cli/commands/drive.py index 0c7a73c2..899244cf 100644 --- a/pyicloud/cli/commands/drive.py +++ b/pyicloud/cli/commands/drive.py @@ -14,12 +14,14 @@ write_response_to_path, ) from pyicloud.cli.normalize import normalize_drive_node +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Drive files.") @app.command("list") +@with_execution_context_options def drive_list( ctx: typer.Context, path: str = typer.Argument("/", help="Drive path, for example /Documents."), @@ -71,6 +73,7 @@ def drive_list( @app.command("download") +@with_execution_context_options def drive_download( ctx: typer.Context, path: str = typer.Argument(..., help="Drive path to the file."), diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index eb52b05e..8d6589c9 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -6,12 +6,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_alias +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Manage Hide My Email aliases.") @app.command("list") +@with_execution_context_options def hidemyemail_list(ctx: typer.Context) -> None: """List Hide My Email aliases.""" @@ -37,6 +39,7 @@ def hidemyemail_list(ctx: typer.Context) -> None: @app.command("generate") +@with_execution_context_options def hidemyemail_generate(ctx: typer.Context) -> None: """Generate a new relay address.""" @@ -51,6 +54,7 @@ def hidemyemail_generate(ctx: typer.Context) -> None: @app.command("reserve") +@with_execution_context_options def hidemyemail_reserve( ctx: typer.Context, email: str = typer.Argument(...), @@ -72,6 +76,7 @@ def hidemyemail_reserve( @app.command("update") +@with_execution_context_options def hidemyemail_update( ctx: typer.Context, anonymous_id: str = typer.Argument(...), @@ -93,6 +98,7 @@ def hidemyemail_update( @app.command("deactivate") +@with_execution_context_options def hidemyemail_deactivate( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: @@ -110,6 +116,7 @@ def hidemyemail_deactivate( @app.command("reactivate") +@with_execution_context_options def hidemyemail_reactivate( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: @@ -127,6 +134,7 @@ def hidemyemail_reactivate( @app.command("delete") +@with_execution_context_options def hidemyemail_delete( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: diff --git a/pyicloud/cli/commands/notes.py b/pyicloud/cli/commands/notes.py index 34e93d57..f277079b 100644 --- a/pyicloud/cli/commands/notes.py +++ b/pyicloud/cli/commands/notes.py @@ -10,12 +10,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import select_recent_notes +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect, render, and export Notes.") @app.command("recent") +@with_execution_context_options def notes_recent( ctx: typer.Context, limit: int = typer.Option(10, "--limit", min=1, help="Maximum notes to show."), @@ -46,6 +48,7 @@ def notes_recent( @app.command("folders") +@with_execution_context_options def notes_folders(ctx: typer.Context) -> None: """List note folders.""" @@ -65,6 +68,7 @@ def notes_folders(ctx: typer.Context) -> None: @app.command("list") +@with_execution_context_options def notes_list( ctx: typer.Context, folder_id: Optional[str] = typer.Option(None, "--folder-id", help="Folder id."), @@ -103,6 +107,7 @@ def notes_list( @app.command("get") +@with_execution_context_options def notes_get( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -133,6 +138,7 @@ def notes_get( @app.command("render") +@with_execution_context_options def notes_render( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -158,6 +164,7 @@ def notes_render( @app.command("export") +@with_execution_context_options def notes_export( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -191,6 +198,7 @@ def notes_export( @app.command("changes") +@with_execution_context_options def notes_changes( ctx: typer.Context, since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), diff --git a/pyicloud/cli/commands/photos.py b/pyicloud/cli/commands/photos.py index bcb280d7..82603b61 100644 --- a/pyicloud/cli/commands/photos.py +++ b/pyicloud/cli/commands/photos.py @@ -10,12 +10,14 @@ from pyicloud.cli.context import CLIAbort, get_state, service_call from pyicloud.cli.normalize import normalize_album, normalize_photo +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Photos.") @app.command("albums") +@with_execution_context_options def photos_albums(ctx: typer.Context) -> None: """List photo albums.""" @@ -36,6 +38,7 @@ def photos_albums(ctx: typer.Context) -> None: @app.command("list") +@with_execution_context_options def photos_list( ctx: typer.Context, album: Optional[str] = typer.Option( @@ -75,6 +78,7 @@ def photos_list( @app.command("download") +@with_execution_context_options def photos_download( ctx: typer.Context, photo_id: str = typer.Argument(..., help="Photo asset id."), diff --git a/pyicloud/cli/commands/reminders.py b/pyicloud/cli/commands/reminders.py index 037f4aab..2bbfc35c 100644 --- a/pyicloud/cli/commands/reminders.py +++ b/pyicloud/cli/commands/reminders.py @@ -8,12 +8,14 @@ import typer from pyicloud.cli.context import get_state, parse_datetime, service_call +from pyicloud.cli.options import with_execution_context_options from pyicloud.cli.output import console_table, format_color_value app = typer.Typer(help="Inspect and mutate Reminders.") @app.command("lists") +@with_execution_context_options def reminders_lists(ctx: typer.Context) -> None: """List reminder lists.""" @@ -36,6 +38,7 @@ def reminders_lists(ctx: typer.Context) -> None: @app.command("list") +@with_execution_context_options def reminders_list( ctx: typer.Context, list_id: Optional[str] = typer.Option(None, "--list-id", help="List id."), @@ -82,6 +85,7 @@ def reminders_list( @app.command("get") +@with_execution_context_options def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> None: """Get one reminder.""" @@ -99,6 +103,7 @@ def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> @app.command("create") +@with_execution_context_options def reminders_create( ctx: typer.Context, list_id: str = typer.Option(..., "--list-id", help="Target list id."), @@ -132,6 +137,7 @@ def reminders_create( @app.command("set-status") +@with_execution_context_options def reminders_set_status( ctx: typer.Context, reminder_id: str = typer.Argument(...), @@ -151,6 +157,7 @@ def reminders_set_status( @app.command("delete") +@with_execution_context_options def reminders_delete( ctx: typer.Context, reminder_id: str = typer.Argument(...) ) -> None: @@ -167,6 +174,7 @@ def reminders_delete( @app.command("changes") +@with_execution_context_options def reminders_changes( ctx: typer.Context, since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index 5f7231d9..8fc83724 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -4,6 +4,7 @@ import logging from contextlib import ExitStack +from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum from pathlib import Path, PurePosixPath @@ -21,6 +22,8 @@ from .account_index import AccountIndexEntry, prune_accounts, remember_account from .output import OutputFormat, write_json +EXECUTION_CONTEXT_OVERRIDES_META_KEY = "execution_context_overrides" + class CLIAbort(RuntimeError): """Abort execution with a user-facing message.""" @@ -46,6 +49,43 @@ def logging_level(self) -> int: return logging.WARNING +@dataclass(frozen=True) +class CLIInvocationDefaults: + """Root-level execution context defaults captured before leaf parsing.""" + + username: str + password: Optional[str] + china_mainland: bool + interactive: bool + delete_from_keyring: bool + accept_terms: bool + with_family: bool + session_dir: Optional[str] + http_proxy: Optional[str] + https_proxy: Optional[str] + no_verify_ssl: bool + log_level: LogLevel + output_format: OutputFormat + + +@dataclass(frozen=True) +class CommandOverrides: + """Leaf command execution-context overrides.""" + + username: Optional[str] = None + password: Optional[str] = None + china_mainland: Optional[bool] = None + interactive: Optional[bool] = None + accept_terms: Optional[bool] = None + with_family: Optional[bool] = None + session_dir: Optional[str] = None + http_proxy: Optional[str] = None + https_proxy: Optional[str] = None + no_verify_ssl: Optional[bool] = None + log_level: Optional[LogLevel] = None + output_format: Optional[OutputFormat] = None + + class CLIState: """Shared CLI state and authenticated API access.""" @@ -86,6 +126,75 @@ def __init__( self._probe_api: Optional[PyiCloudService] = None self._resolved_username: Optional[str] = self.username or None + @classmethod + def from_invocation( + cls, + defaults: CLIInvocationDefaults, + overrides: Optional[CommandOverrides] = None, + ) -> "CLIState": + """Build CLI state from root defaults plus leaf overrides.""" + + overrides = overrides or CommandOverrides() + return cls( + username=( + defaults.username if overrides.username is None else overrides.username + ), + password=( + defaults.password if overrides.password is None else overrides.password + ), + china_mainland=( + defaults.china_mainland + if overrides.china_mainland is None + else overrides.china_mainland + ), + interactive=( + defaults.interactive + if overrides.interactive is None + else overrides.interactive + ), + delete_from_keyring=defaults.delete_from_keyring, + accept_terms=( + defaults.accept_terms + if overrides.accept_terms is None + else overrides.accept_terms + ), + with_family=( + defaults.with_family + if overrides.with_family is None + else overrides.with_family + ), + session_dir=( + defaults.session_dir + if overrides.session_dir is None + else overrides.session_dir + ), + http_proxy=( + defaults.http_proxy + if overrides.http_proxy is None + else overrides.http_proxy + ), + https_proxy=( + defaults.https_proxy + if overrides.https_proxy is None + else overrides.https_proxy + ), + no_verify_ssl=( + defaults.no_verify_ssl + if overrides.no_verify_ssl is None + else overrides.no_verify_ssl + ), + log_level=( + defaults.log_level + if overrides.log_level is None + else overrides.log_level + ), + output_format=( + defaults.output_format + if overrides.output_format is None + else overrides.output_format + ), + ) + @property def has_explicit_username(self) -> bool: """Return whether the user explicitly passed --username.""" @@ -407,12 +516,24 @@ def active_session_probes(self) -> list[tuple[PyiCloudService, dict[str, Any]]]: def get_state(ctx: typer.Context) -> CLIState: - """Return the shared CLI state for a command.""" + """Return the resolved CLI state for a leaf command.""" - state = ctx.obj - if not isinstance(state, CLIState): + root_ctx = ctx.find_root() + state = root_ctx.obj + if isinstance(state, CLIState): + return state + if not isinstance(state, CLIInvocationDefaults): raise RuntimeError("CLI state was not initialized.") - return state + + overrides = CommandOverrides( + **ctx.meta.get(EXECUTION_CONTEXT_OVERRIDES_META_KEY, {}) + ) + resolved = CLIState.from_invocation(state, overrides) + resolved.open() + root_ctx.call_on_close(resolved.close) + root_ctx.obj = resolved + ctx.obj = resolved + return resolved def service_call(label: str, fn): diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py new file mode 100644 index 00000000..3fb30b91 --- /dev/null +++ b/pyicloud/cli/options.py @@ -0,0 +1,186 @@ +"""Shared execution-context options for Typer CLI leaf commands.""" + +from __future__ import annotations + +import inspect +from functools import wraps +from typing import Any, Callable, Optional, TypeVar + +import click +import typer + +from .context import EXECUTION_CONTEXT_OVERRIDES_META_KEY, LogLevel +from .output import OutputFormat + +CommandCallback = TypeVar("CommandCallback", bound=Callable[..., Any]) +EXECUTION_CONTEXT_PANEL = "Execution Context" + + +def _execution_context_parameters() -> list[inspect.Parameter]: + """Return shared final-command execution-context parameters.""" + + return [ + inspect.Parameter( + "username", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[str], + default=typer.Option( + None, + "--username", + help="Apple ID username.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "password", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[str], + default=typer.Option( + None, + "--password", + help="Apple ID password.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "china_mainland", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[bool], + default=typer.Option( + None, + "--china-mainland", + help="Use China mainland Apple web service endpoints.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "interactive", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[bool], + default=typer.Option( + None, + "--interactive/--non-interactive", + help="Enable or disable interactive prompts.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "accept_terms", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[bool], + default=typer.Option( + None, + "--accept-terms", + help="Automatically accept pending Apple iCloud web terms.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "with_family", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[bool], + default=typer.Option( + None, + "--with-family", + help="Include family devices in Find My device listings.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "session_dir", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[str], + default=typer.Option( + None, + "--session-dir", + help="Directory to store session and cookie files.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "http_proxy", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[str], + default=typer.Option( + None, + "--http-proxy", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "https_proxy", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[str], + default=typer.Option( + None, + "--https-proxy", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "no_verify_ssl", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[bool], + default=typer.Option( + None, + "--no-verify-ssl", + help="Disable SSL verification for requests.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "log_level", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[LogLevel], + default=typer.Option( + None, + "--log-level", + case_sensitive=False, + help="Logging level for pyicloud internals.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + inspect.Parameter( + "output_format", + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[OutputFormat], + default=typer.Option( + None, + "--format", + case_sensitive=False, + help="Output format for command results.", + rich_help_panel=EXECUTION_CONTEXT_PANEL, + ), + ), + ] + + +def with_execution_context_options(fn: CommandCallback) -> CommandCallback: + """Inject shared execution-context options onto a leaf command.""" + + signature = inspect.signature(fn) + extra_parameters = _execution_context_parameters() + parameter_names = [parameter.name for parameter in extra_parameters] + + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any): + ctx = kwargs.get("ctx") + if ctx is None: + ctx = next( + (arg for arg in args if isinstance(arg, click.Context)), + None, + ) + if ctx is None: + raise RuntimeError("CLI context was not provided.") + + overrides = ctx.meta.setdefault(EXECUTION_CONTEXT_OVERRIDES_META_KEY, {}) + for name in parameter_names: + value = kwargs.pop(name, None) + if value is not None: + overrides[name] = value + return fn(*args, **kwargs) + + wrapper.__signature__ = signature.replace( + parameters=list(signature.parameters.values()) + extra_parameters + ) + return wrapper # type: ignore[return-value] diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 2054a7f3..6b0df705 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -575,13 +575,35 @@ def _invoke( return runner.invoke(app, cli_args) +def _invoke_with_cli_args( + fake_api: FakeAPI, + cli_args: list[str], + *, + keyring_passwords: Optional[set[str]] = None, +): + runner = _runner() + with ( + patch.object(context_module, "PyiCloudService", return_value=fake_api), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: candidate in (keyring_passwords or set()), + ), + ): + return runner.invoke(app, cli_args) + + def test_root_help() -> None: """The root command should expose the service subcommands and format option.""" result = _runner().invoke(app, ["--help"]) assert result.exit_code == 0 assert "--username" in result.stdout - assert "Optional when a" in result.stdout + assert "before the command or on the final command" in result.stdout assert "--format" in result.stdout assert "--json" not in result.stdout assert "--debug" not in result.stdout @@ -619,6 +641,17 @@ def test_group_help() -> None: assert result.exit_code == 0 +def test_leaf_help_includes_execution_context_options() -> None: + """Leaf command help should show shared execution-context options.""" + + result = _runner().invoke(app, ["account", "summary", "--help"]) + + assert result.exit_code == 0 + assert "--username" in result.stdout + assert "--format" in result.stdout + assert "--session-dir" in result.stdout + + def test_account_summary_command() -> None: """Account summary should render the storage overview.""" @@ -638,6 +671,162 @@ def test_format_option_outputs_json() -> None: assert payload["devices_count"] == 1 +def test_command_local_format_option_outputs_json() -> None: + """Leaf commands should accept --format after the final subcommand.""" + + session_dir = _unique_session_dir("leaf-format") + result = _invoke_with_cli_args( + FakeAPI(session_dir=session_dir), + [ + "--username", + "user@example.com", + "--password", + "secret", + "--session-dir", + str(session_dir), + "--non-interactive", + "account", + "summary", + "--format", + "json", + ], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 0 + assert payload["account_name"] == "user@example.com" + + +def test_leaf_execution_context_overrides_root_values() -> None: + """Leaf execution-context options should take precedence over root values.""" + + session_dir = _unique_session_dir("leaf-precedence") + fake_api = FakeAPI(username="leaf@example.com", session_dir=session_dir) + + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "leaf@example.com" + assert kwargs["cookie_directory"] == str(session_dir) + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + ): + result = _runner().invoke( + app, + [ + "--username", + "root@example.com", + "--password", + "root-secret", + "--session-dir", + "/tmp/root-session", + "--format", + "json", + "--non-interactive", + "auth", + "login", + "--username", + "leaf@example.com", + "--password", + "leaf-secret", + "--session-dir", + str(session_dir), + "--format", + "text", + ], + ) + + assert result.exit_code == 0 + assert "Authenticated session is ready." in result.stdout + assert result.stdout.lstrip()[0] != "{" + + +def test_auth_login_accepts_command_local_username() -> None: + """Auth login should accept --username after the final subcommand.""" + + session_dir = _unique_session_dir("leaf-username") + fake_api = FakeAPI(username="leaf@example.com", session_dir=session_dir) + + def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: + assert apple_id == "leaf@example.com" + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + ): + result = _runner().invoke( + app, + [ + "--password", + "secret", + "--session-dir", + str(session_dir), + "--non-interactive", + "auth", + "login", + "--username", + "leaf@example.com", + ], + ) + + assert result.exit_code == 0 + assert "leaf@example.com" in result.stdout + + +def test_leaf_session_dir_option_is_used_for_service_commands() -> None: + """Leaf --session-dir should be honored by service commands.""" + + session_dir = _unique_session_dir("leaf-session-dir") + fake_api = FakeAPI(session_dir=session_dir) + + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "user@example.com" + assert kwargs["cookie_directory"] == str(session_dir) + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + ): + result = _runner().invoke( + app, + [ + "--username", + "user@example.com", + "--password", + "secret", + "--non-interactive", + "account", + "summary", + "--session-dir", + str(session_dir), + ], + ) + + assert result.exit_code == 0 + assert "Account: user@example.com" in result.stdout + + def test_default_log_level_is_warning() -> None: """Authenticated commands should default pyicloud logs to warning.""" @@ -703,6 +892,19 @@ def test_delete_from_keyring() -> None: assert account_index_module.load_accounts(session_dir) == {} +def test_delete_from_keyring_remains_root_only() -> None: + """Utility flags like --delete-from-keyring should remain root-only.""" + + result = _runner().invoke( + app, + ["auth", "login", "--delete-from-keyring"], + ) + + assert result.exit_code != 0 + combined_output = result.stdout + result.stderr + assert "No such option: --delete-from-keyring" in combined_output + + def test_auth_status_probe_is_non_interactive() -> None: """Auth status should probe persisted sessions without prompting for login.""" From a6b061104b76e19e2027ce980b6a0c82662bf11b Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Tue, 17 Mar 2026 23:40:35 +0100 Subject: [PATCH 05/18] Polish CLI help and shared messages --- pyicloud/cli/app.py | 71 ++++++++++++++++++++++------------- pyicloud/cli/commands/auth.py | 18 +++------ pyicloud/cli/context.py | 30 +++++++++------ pyicloud/cli/options.py | 44 +++++++++++++++++----- tests/test_cmdline.py | 27 +++++++++++-- 5 files changed, 127 insertions(+), 63 deletions(-) diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index 28ee58b5..ddeb5dab 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -15,6 +15,18 @@ from pyicloud.cli.commands.photos import app as photos_app from pyicloud.cli.commands.reminders import app as reminders_app from pyicloud.cli.context import CLIAbort, CLIInvocationDefaults, CLIState, LogLevel +from pyicloud.cli.options import ( + ACCEPT_TERMS_OPTION_HELP, + CHINA_MAINLAND_OPTION_HELP, + INTERACTIVE_OPTION_HELP, + LOG_LEVEL_OPTION_HELP, + NO_VERIFY_SSL_OPTION_HELP, + PASSWORD_OPTION_HELP, + ROOT_OUTPUT_FORMAT_OPTION_HELP, + ROOT_USERNAME_OPTION_HELP, + SESSION_DIR_OPTION_HELP, + WITH_FAMILY_OPTION_HELP, +) from pyicloud.cli.output import OutputFormat app = typer.Typer( @@ -26,16 +38,29 @@ pretty_exceptions_show_locals=False, ) -app.add_typer(account_app, name="account") -app.add_typer(auth_app, name="auth") -app.add_typer(devices_app, name="devices") -app.add_typer(calendar_app, name="calendar") -app.add_typer(contacts_app, name="contacts") -app.add_typer(drive_app, name="drive") -app.add_typer(photos_app, name="photos") -app.add_typer(hidemyemail_app, name="hidemyemail") -app.add_typer(reminders_app, name="reminders") -app.add_typer(notes_app, name="notes") + +def _group_root(ctx: typer.Context) -> None: + """Show mounted group help when invoked without a subcommand.""" + + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + + +app.add_typer(account_app, name="account", invoke_without_command=True, callback=_group_root) +app.add_typer(auth_app, name="auth", invoke_without_command=True, callback=_group_root) +app.add_typer(devices_app, name="devices", invoke_without_command=True, callback=_group_root) +app.add_typer(calendar_app, name="calendar", invoke_without_command=True, callback=_group_root) +app.add_typer(contacts_app, name="contacts", invoke_without_command=True, callback=_group_root) +app.add_typer(drive_app, name="drive", invoke_without_command=True, callback=_group_root) +app.add_typer(photos_app, name="photos", invoke_without_command=True, callback=_group_root) +app.add_typer( + hidemyemail_app, name="hidemyemail", invoke_without_command=True, callback=_group_root +) +app.add_typer( + reminders_app, name="reminders", invoke_without_command=True, callback=_group_root +) +app.add_typer(notes_app, name="notes", invoke_without_command=True, callback=_group_root) @app.callback(invoke_without_command=True) @@ -44,25 +69,22 @@ def root( username: str = typer.Option( "", "--username", - help=( - "Apple ID username. Can be provided before the command or on the final " - "command. Optional when a command can infer a single account context." - ), + help=ROOT_USERNAME_OPTION_HELP, ), password: str | None = typer.Option( None, "--password", - help="Apple ID password. If omitted, pyicloud will use the system keyring or prompt interactively.", + help=PASSWORD_OPTION_HELP, ), china_mainland: bool = typer.Option( False, "--china-mainland", - help="Use China mainland Apple web service endpoints.", + help=CHINA_MAINLAND_OPTION_HELP, ), interactive: bool = typer.Option( True, "--interactive/--non-interactive", - help="Enable or disable interactive prompts.", + help=INTERACTIVE_OPTION_HELP, ), delete_from_keyring: bool = typer.Option( False, @@ -72,39 +94,36 @@ def root( accept_terms: bool = typer.Option( False, "--accept-terms", - help="Automatically accept pending Apple iCloud web terms.", + help=ACCEPT_TERMS_OPTION_HELP, ), with_family: bool = typer.Option( False, "--with-family", - help="Include family devices in Find My device listings.", + help=WITH_FAMILY_OPTION_HELP, ), session_dir: str | None = typer.Option( None, "--session-dir", - help="Directory to store session and cookie files.", + help=SESSION_DIR_OPTION_HELP, ), http_proxy: str | None = typer.Option(None, "--http-proxy"), https_proxy: str | None = typer.Option(None, "--https-proxy"), no_verify_ssl: bool = typer.Option( False, "--no-verify-ssl", - help="Disable SSL verification for requests.", + help=NO_VERIFY_SSL_OPTION_HELP, ), log_level: LogLevel = typer.Option( LogLevel.WARNING, "--log-level", case_sensitive=False, - help="Logging level for pyicloud internals.", + help=LOG_LEVEL_OPTION_HELP, ), output_format: OutputFormat = typer.Option( OutputFormat.TEXT, "--format", case_sensitive=False, - help=( - "Output format for command results. Can be provided before the command " - "or on the final command." - ), + help=ROOT_OUTPUT_FORMAT_OPTION_HELP, ), ) -> None: """Initialize shared CLI state.""" diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index 8898a893..c96319cd 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -35,10 +35,7 @@ def auth_status(ctx: typer.Context) -> None: if state.json_output: state.write_json({"authenticated": False, "accounts": []}) return - state.console.print( - "You are not logged into any iCloud accounts. To log in, run: " - "icloud --username auth login" - ) + state.console.print(state.not_logged_in_message()) return payloads = [_auth_payload(state, api, status) for api, status in active_probes] @@ -187,18 +184,13 @@ def auth_logout( if state.json_output: state.write_json({"authenticated": False, "accounts": []}) return - state.console.print( - "You are not logged into any iCloud accounts. To log in, run: " - "icloud --username auth login" - ) + state.console.print(state.not_logged_in_message()) return if len(active_probes) > 1: - accounts = "\n".join( - f" - {api.account_name}" for api, _status in active_probes - ) raise CLIAbort( - "Multiple logged-in iCloud accounts were found; pass --username to choose one.\n" - f"{accounts}" + state.multiple_logged_in_accounts_message( + [api.account_name for api, _status in active_probes] + ) ) api, _status = active_probes[0] diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index 8fc83724..3604509b 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -301,20 +301,30 @@ def _resolve_username(self) -> str: self._resolved_username = accounts[0]["username"] return self._resolved_username - def _not_logged_in_message(self) -> str: + def not_logged_in_message(self) -> str: """Return the default message for commands that require an active session.""" return ( "You are not logged into any iCloud accounts. To log in, run: " - "icloud --username auth login" + "icloud auth login --username " ) - def _not_logged_in_for_account_message(self, username: str) -> str: + def not_logged_in_for_account_message(self, username: str) -> str: """Return the message for account-targeted commands without an active session.""" return ( f"You are not logged into iCloud for {username}. Run: " - f"icloud --username {username} auth login" + f"icloud auth login --username {username}" + ) + + @staticmethod + def multiple_logged_in_accounts_message(usernames: list[str]) -> str: + """Return the message for ambiguous active-session account selection.""" + + options = "\n".join(f" - {username}" for username in usernames) + return ( + "Multiple logged-in iCloud accounts were found; pass --username to choose one.\n" + f"{options}" ) def _password_for_login(self, username: str) -> Optional[str]: @@ -438,21 +448,19 @@ def get_api(self) -> PyiCloudService: api = self.build_probe_api(username) status = api.get_auth_status() if not status["authenticated"]: - raise CLIAbort(self._not_logged_in_for_account_message(username)) + raise CLIAbort(self.not_logged_in_for_account_message(username)) self._api = api self.remember_account(api) return api active_probes = self.active_session_probes() if not active_probes: - raise CLIAbort(self._not_logged_in_message()) + raise CLIAbort(self.not_logged_in_message()) if len(active_probes) > 1: - accounts = "\n".join( - f" - {api.account_name}" for api, _status in active_probes - ) raise CLIAbort( - "Multiple logged-in iCloud accounts were found; pass --username to choose one.\n" - f"{accounts}" + self.multiple_logged_in_accounts_message( + [api.account_name for api, _status in active_probes] + ) ) api, _status = active_probes[0] diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py index 3fb30b91..d67c2945 100644 --- a/pyicloud/cli/options.py +++ b/pyicloud/cli/options.py @@ -14,6 +14,30 @@ CommandCallback = TypeVar("CommandCallback", bound=Callable[..., Any]) EXECUTION_CONTEXT_PANEL = "Execution Context" +EXECUTION_CONTEXT_PLACEMENT_HELP = ( + " Can be provided before the command or on the final command." +) + +USERNAME_OPTION_HELP = "Apple ID username." +ROOT_USERNAME_OPTION_HELP = ( + USERNAME_OPTION_HELP + + EXECUTION_CONTEXT_PLACEMENT_HELP + + " Optional when a command can infer a single account context." +) +PASSWORD_OPTION_HELP = ( + "Apple ID password. If omitted, pyicloud will use the system keyring or prompt interactively." +) +CHINA_MAINLAND_OPTION_HELP = "Use China mainland Apple web service endpoints." +INTERACTIVE_OPTION_HELP = "Enable or disable interactive prompts." +ACCEPT_TERMS_OPTION_HELP = "Automatically accept pending Apple iCloud web terms." +WITH_FAMILY_OPTION_HELP = "Include family devices in Find My device listings." +SESSION_DIR_OPTION_HELP = "Directory to store session and cookie files." +NO_VERIFY_SSL_OPTION_HELP = "Disable SSL verification for requests." +LOG_LEVEL_OPTION_HELP = "Logging level for pyicloud internals." +OUTPUT_FORMAT_OPTION_HELP = "Output format for command results." +ROOT_OUTPUT_FORMAT_OPTION_HELP = ( + OUTPUT_FORMAT_OPTION_HELP + EXECUTION_CONTEXT_PLACEMENT_HELP +) def _execution_context_parameters() -> list[inspect.Parameter]: @@ -27,7 +51,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--username", - help="Apple ID username.", + help=USERNAME_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -38,7 +62,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--password", - help="Apple ID password.", + help=PASSWORD_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -49,7 +73,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--china-mainland", - help="Use China mainland Apple web service endpoints.", + help=CHINA_MAINLAND_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -60,7 +84,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--interactive/--non-interactive", - help="Enable or disable interactive prompts.", + help=INTERACTIVE_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -71,7 +95,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--accept-terms", - help="Automatically accept pending Apple iCloud web terms.", + help=ACCEPT_TERMS_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -82,7 +106,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--with-family", - help="Include family devices in Find My device listings.", + help=WITH_FAMILY_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -93,7 +117,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--session-dir", - help="Directory to store session and cookie files.", + help=SESSION_DIR_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -124,7 +148,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: default=typer.Option( None, "--no-verify-ssl", - help="Disable SSL verification for requests.", + help=NO_VERIFY_SSL_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -136,7 +160,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: None, "--log-level", case_sensitive=False, - help="Logging level for pyicloud internals.", + help=LOG_LEVEL_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), @@ -148,7 +172,7 @@ def _execution_context_parameters() -> list[inspect.Parameter]: None, "--format", case_sensitive=False, - help="Output format for command results.", + help=OUTPUT_FORMAT_OPTION_HELP, rich_help_panel=EXECUTION_CONTEXT_PANEL, ), ), diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 6b0df705..65750563 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -641,6 +641,27 @@ def test_group_help() -> None: assert result.exit_code == 0 +def test_bare_group_invocation_shows_help() -> None: + """Bare group invocation should show help instead of a missing-command error.""" + + for command in ( + "account", + "auth", + "devices", + "calendar", + "contacts", + "drive", + "photos", + "hidemyemail", + "reminders", + "notes", + ): + result = _runner().invoke(app, [command]) + assert result.exit_code == 0 + assert "Usage:" in result.stdout + assert "Missing command" not in (result.stdout + result.stderr) + + def test_leaf_help_includes_execution_context_options() -> None: """Leaf command help should show shared execution-context options.""" @@ -850,7 +871,7 @@ def test_no_local_accounts_require_username() -> None: assert ( result.exception.args[0] == "You are not logged into any iCloud accounts. To log in, run: " - "icloud --username auth login" + "icloud auth login --username " ) @@ -1060,13 +1081,13 @@ def test_single_known_account_supports_implicit_local_context() -> None: assert ( post_logout_account_result.exception.args[0] == "You are not logged into any iCloud accounts. To log in, run: " - "icloud --username auth login" + "icloud auth login --username " ) assert post_logout_explicit_result.exit_code != 0 assert ( post_logout_explicit_result.exception.args[0] == "You are not logged into iCloud for solo@example.com. Run: " - "icloud --username solo@example.com auth login" + "icloud auth login --username solo@example.com" ) assert login_result.exit_code == 0 assert [ From 582f87061bbd5fd5380036d171d27c5b241bab16 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 00:49:20 +0100 Subject: [PATCH 06/18] Make CLI options leaf-only --- README.md | 67 ++--- pyicloud/base.py | 12 +- pyicloud/cli/account_index.py | 12 +- pyicloud/cli/app.py | 122 +-------- pyicloud/cli/commands/account.py | 10 +- pyicloud/cli/commands/auth.py | 55 ++++- pyicloud/cli/commands/calendar.py | 6 +- pyicloud/cli/commands/contacts.py | 6 +- pyicloud/cli/commands/devices.py | 16 +- pyicloud/cli/commands/drive.py | 6 +- pyicloud/cli/commands/hidemyemail.py | 16 +- pyicloud/cli/commands/notes.py | 16 +- pyicloud/cli/commands/photos.py | 8 +- pyicloud/cli/commands/reminders.py | 16 +- pyicloud/cli/context.py | 162 ++++-------- pyicloud/cli/options.py | 327 +++++++++++++++---------- tests/test_cmdline.py | 354 +++++++++++++++++++-------- 17 files changed, 644 insertions(+), 567 deletions(-) diff --git a/README.md b/README.md index 8c0640bb..baa8ec06 100644 --- a/README.md +++ b/README.md @@ -61,37 +61,20 @@ subcommands such as `account`, `auth`, `devices`, `calendar`, `contacts`, `drive`, `photos`, `hidemyemail`, `reminders`, and `notes`. -Global options such as `--username`, `--password`, `--session-dir`, -`--accept-terms`, `--with-family`, `--log-level`, and `--format` apply -before the service subcommand. `--username` acts as an explicit override; -if exactly one local account is known in the selected session directory, -the CLI can infer it automatically. If multiple local accounts are known, -the CLI will ask you to pass `--username` explicitly. +Command options belong on the final command that uses them. For example: ```console -$ icloud --username=jappleseed@apple.com --format json account summary +$ icloud auth login --username jappleseed@apple.com +$ icloud account summary --format json ``` -The main global options are: - -- `--username`: Apple ID username; optional when exactly one local account is discoverable -- `--password`: Apple ID password; if omitted, pyicloud uses the system keyring or prompts interactively -- `--china-mainland`: use China mainland Apple web service endpoints -- `--interactive/--non-interactive`: enable or disable prompts for auth flows and keyring storage -- `--delete-from-keyring`: delete the stored password for `--username` and exit -- `--accept-terms`: automatically accept pending Apple web terms when possible -- `--with-family`: include family devices in Find My listings -- `--session-dir`: custom directory for session and cookie files -- `--http-proxy` / `--https-proxy`: per-protocol proxy settings -- `--no-verify-ssl`: disable TLS verification for requests -- `--log-level`: one of `error`, `warning`, `info`, or `debug` -- `--format`: one of `text` or `json` +The root command only exposes help and shell-completion utilities. You can store your password in the system keyring using the command-line tool: ```console -$ icloud --username=jappleseed@apple.com account summary +$ icloud auth login --username jappleseed@apple.com Enter iCloud password for jappleseed@apple.com: Save password in keyring? (y/N) ``` @@ -107,27 +90,28 @@ Examples: $ icloud auth status $ icloud auth login $ icloud auth login --username jappleseed@apple.com +$ icloud auth login --username jappleseed@apple.com --china-mainland +$ icloud auth login --username jappleseed@apple.com --accept-terms $ icloud auth logout $ icloud auth logout --keep-trusted $ icloud auth logout --all-sessions $ icloud auth logout --keep-trusted --all-sessions $ icloud auth logout --remove-keyring +$ icloud auth keyring delete --username jappleseed@apple.com $ icloud account summary $ icloud account summary --format json $ icloud devices list --locate +$ icloud devices list --with-family $ icloud devices list --session-dir /tmp/pyicloud-test --format json $ icloud devices show "Jacob's iPhone" $ icloud devices export "Jacob's iPhone" --output ./iphone.json -$ icloud --username=jappleseed@apple.com auth login -$ icloud --format json account summary -$ icloud --username=jappleseed@apple.com calendar events --period week -$ icloud --username=jappleseed@apple.com contacts me -$ icloud --username=jappleseed@apple.com drive list /Documents -$ icloud --username=jappleseed@apple.com photos albums -$ icloud --username=jappleseed@apple.com hidemyemail list -$ icloud --username=jappleseed@apple.com reminders lists -$ icloud --username=jappleseed@apple.com notes recent --limit 5 -$ icloud --username=jappleseed@apple.com --format json account summary +$ icloud calendar events --username jappleseed@apple.com --period week +$ icloud contacts me --username jappleseed@apple.com +$ icloud drive list /Documents --username jappleseed@apple.com +$ icloud photos albums --username jappleseed@apple.com +$ icloud hidemyemail list --username jappleseed@apple.com +$ icloud reminders lists --username jappleseed@apple.com +$ icloud notes recent --username jappleseed@apple.com --limit 5 ``` ```python @@ -135,11 +119,10 @@ api = PyiCloudService('jappleseed@apple.com') ``` If you would like to delete a password stored in your system keyring, -you can clear a stored password using the `--delete-from-keyring` -command-line option: +use the dedicated keyring subcommand: ```console -$ icloud --username=jappleseed@apple.com --delete-from-keyring +$ icloud auth keyring delete --username jappleseed@apple.com ``` The `auth` command group lets you inspect and manage persisted sessions: @@ -150,20 +133,15 @@ The `auth` command group lets you inspect and manage persisted sessions: - `icloud auth logout --keep-trusted`: sign out while asking Apple to preserve trusted-browser state for the next login - `icloud auth logout --all-sessions`: attempt to sign out all browser sessions - `icloud auth logout --remove-keyring`: also delete the stored password for the selected account +- `icloud auth keyring delete --username `: delete the stored password without logging out - `icloud auth logout --keep-trusted --all-sessions`: experimental combination that requests both behaviors -Execution-context options such as `--username`, `--password`, -`--session-dir`, and `--format` can be provided either before the -command or on the final command. The preferred style is to place them on -the final command, for example `icloud account summary --format json` or -`icloud auth login --username=`. - When only one local account is known, `auth login` can omit `--username`. Service commands, `auth status`, and `auth logout` without `--username` operate on active logged-in sessions only, similar to `gh`. If no active sessions exist, service commands and `auth status` report that no iCloud accounts are logged in and direct you to -`icloud --username= auth login`. If multiple logged-in +`icloud auth login --username `. If multiple logged-in accounts exist, pass `--username` to disambiguate account-targeted operations. @@ -174,9 +152,8 @@ instead of the raw wire field names. Stored passwords in the system keyring are treated separately from authenticated sessions. A plain `icloud auth logout` ends the session but keeps the stored password. Use `icloud auth logout --remove-keyring` -or `icloud --username= --delete-from-keyring` if you also want -to forget the saved password. The utility flag `--delete-from-keyring` -remains root-only. +or `icloud auth keyring delete --username ` if you also want +to forget the saved password. Migration notes for the previous Find My-focused CLI: diff --git a/pyicloud/base.py b/pyicloud/base.py index d0c6c639..2db001f7 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -148,14 +148,16 @@ def __init__( verify: bool = True, client_id: Optional[str] = None, with_family: bool = True, - china_mainland: bool = False, + china_mainland: Optional[bool] = None, accept_terms: bool = False, refresh_interval: float | None = None, *, authenticate: bool = True, ) -> None: self._is_china_mainland: bool = ( - china_mainland or environ.get("icloud_china", "0") == "1" + environ.get("icloud_china", "0") == "1" + if china_mainland is None + else china_mainland ) self._setup_endpoints() @@ -219,6 +221,12 @@ def __init__( if authenticate: self.authenticate() + @property + def is_china_mainland(self) -> bool: + """Return whether the current service uses China mainland endpoints.""" + + return self._is_china_mainland + def authenticate( self, force_refresh: bool = False, service: Optional[str] = None ) -> None: diff --git a/pyicloud/cli/account_index.py b/pyicloud/cli/account_index.py index b45b596f..5385cdd2 100644 --- a/pyicloud/cli/account_index.py +++ b/pyicloud/cli/account_index.py @@ -10,13 +10,14 @@ ACCOUNT_INDEX_FILENAME = "accounts.json" -class AccountIndexEntry(TypedDict): +class AccountIndexEntry(TypedDict, total=False): """Persisted local account metadata.""" username: str last_used_at: str session_path: str cookiejar_path: str + china_mainland: bool def account_index_path(session_root: str | Path) -> Path: @@ -55,6 +56,9 @@ def load_accounts(session_root: str | Path) -> dict[str, AccountIndexEntry]: "session_path": session_path, "cookiejar_path": cookiejar_path, } + china_mainland = entry.get("china_mainland") + if isinstance(china_mainland, bool): + normalized[username]["china_mainland"] = china_mainland return normalized @@ -113,6 +117,7 @@ def remember_account( username: str, session_path: str, cookiejar_path: str, + china_mainland: bool | None, keyring_has: Callable[[str], bool], ) -> AccountIndexEntry: """Upsert one account entry and prune any stale neighbors.""" @@ -120,12 +125,17 @@ def remember_account( accounts = { entry["username"]: entry for entry in prune_accounts(session_root, keyring_has) } + previous = accounts.get(username) entry: AccountIndexEntry = { "username": username, "last_used_at": datetime.now(tz=timezone.utc).isoformat(), "session_path": session_path, "cookiejar_path": cookiejar_path, } + if china_mainland is not None: + entry["china_mainland"] = china_mainland + elif previous is not None and "china_mainland" in previous: + entry["china_mainland"] = previous["china_mainland"] accounts[username] = entry _save_accounts(session_root, accounts) return entry diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index ddeb5dab..727580a1 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -14,26 +14,10 @@ from pyicloud.cli.commands.notes import app as notes_app from pyicloud.cli.commands.photos import app as photos_app from pyicloud.cli.commands.reminders import app as reminders_app -from pyicloud.cli.context import CLIAbort, CLIInvocationDefaults, CLIState, LogLevel -from pyicloud.cli.options import ( - ACCEPT_TERMS_OPTION_HELP, - CHINA_MAINLAND_OPTION_HELP, - INTERACTIVE_OPTION_HELP, - LOG_LEVEL_OPTION_HELP, - NO_VERIFY_SSL_OPTION_HELP, - PASSWORD_OPTION_HELP, - ROOT_OUTPUT_FORMAT_OPTION_HELP, - ROOT_USERNAME_OPTION_HELP, - SESSION_DIR_OPTION_HELP, - WITH_FAMILY_OPTION_HELP, -) -from pyicloud.cli.output import OutputFormat +from pyicloud.cli.context import CLIAbort app = typer.Typer( - help=( - "Command line interface for pyicloud services. Execution-context options " - "may be provided either before the command or on the final command." - ), + help="Command line interface for pyicloud services.", no_args_is_help=True, pretty_exceptions_show_locals=False, ) @@ -63,108 +47,6 @@ def _group_root(ctx: typer.Context) -> None: app.add_typer(notes_app, name="notes", invoke_without_command=True, callback=_group_root) -@app.callback(invoke_without_command=True) -def root( - ctx: typer.Context, - username: str = typer.Option( - "", - "--username", - help=ROOT_USERNAME_OPTION_HELP, - ), - password: str | None = typer.Option( - None, - "--password", - help=PASSWORD_OPTION_HELP, - ), - china_mainland: bool = typer.Option( - False, - "--china-mainland", - help=CHINA_MAINLAND_OPTION_HELP, - ), - interactive: bool = typer.Option( - True, - "--interactive/--non-interactive", - help=INTERACTIVE_OPTION_HELP, - ), - delete_from_keyring: bool = typer.Option( - False, - "--delete-from-keyring", - help="Delete the stored password for --username and exit if no command is given.", - ), - accept_terms: bool = typer.Option( - False, - "--accept-terms", - help=ACCEPT_TERMS_OPTION_HELP, - ), - with_family: bool = typer.Option( - False, - "--with-family", - help=WITH_FAMILY_OPTION_HELP, - ), - session_dir: str | None = typer.Option( - None, - "--session-dir", - help=SESSION_DIR_OPTION_HELP, - ), - http_proxy: str | None = typer.Option(None, "--http-proxy"), - https_proxy: str | None = typer.Option(None, "--https-proxy"), - no_verify_ssl: bool = typer.Option( - False, - "--no-verify-ssl", - help=NO_VERIFY_SSL_OPTION_HELP, - ), - log_level: LogLevel = typer.Option( - LogLevel.WARNING, - "--log-level", - case_sensitive=False, - help=LOG_LEVEL_OPTION_HELP, - ), - output_format: OutputFormat = typer.Option( - OutputFormat.TEXT, - "--format", - case_sensitive=False, - help=ROOT_OUTPUT_FORMAT_OPTION_HELP, - ), -) -> None: - """Initialize shared CLI state.""" - - defaults = CLIInvocationDefaults( - username=username, - password=password, - china_mainland=china_mainland, - interactive=interactive, - delete_from_keyring=delete_from_keyring, - accept_terms=accept_terms, - with_family=with_family, - session_dir=session_dir, - http_proxy=http_proxy, - https_proxy=https_proxy, - no_verify_ssl=no_verify_ssl, - log_level=log_level, - output_format=output_format, - ) - ctx.obj = defaults - - if delete_from_keyring: - state = CLIState.from_invocation(defaults) - state.open() - try: - deleted = state.delete_stored_password() - finally: - state.close() - if ctx.invoked_subcommand is None: - state.console.print( - "Deleted stored password from keyring." - if deleted - else "No stored password was found for that username." - ) - raise typer.Exit() - - if ctx.invoked_subcommand is None: - state.console.print(ctx.get_help()) - raise typer.Exit() - - def main() -> int: """Run the Typer application.""" diff --git a/pyicloud/cli/commands/account.py b/pyicloud/cli/commands/account.py index ad99cb51..bf32c3a1 100644 --- a/pyicloud/cli/commands/account.py +++ b/pyicloud/cli/commands/account.py @@ -11,14 +11,14 @@ normalize_family_member, normalize_storage, ) -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud account metadata.") @app.command("summary") -@with_execution_context_options +@with_service_command_options def account_summary(ctx: typer.Context) -> None: """Show high-level account information.""" @@ -39,7 +39,7 @@ def account_summary(ctx: typer.Context) -> None: @app.command("devices") -@with_execution_context_options +@with_service_command_options def account_devices(ctx: typer.Context) -> None: """List devices associated with the account profile.""" @@ -70,7 +70,7 @@ def account_devices(ctx: typer.Context) -> None: @app.command("family") -@with_execution_context_options +@with_service_command_options def account_family(ctx: typer.Context) -> None: """List family sharing members.""" @@ -101,7 +101,7 @@ def account_family(ctx: typer.Context) -> None: @app.command("storage") -@with_execution_context_options +@with_service_command_options def account_storage(ctx: typer.Context) -> None: """Show iCloud storage usage.""" diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index c96319cd..8f3c9106 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -5,11 +5,30 @@ import typer from pyicloud.cli.context import CLIAbort, get_state -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import ( + with_auth_login_options, + with_auth_session_options, + with_keyring_delete_options, +) from pyicloud.cli.output import console_kv_table, console_table -app = typer.Typer( - help="Manage authentication and sessions for the selected or inferred account." +app = typer.Typer(help="Manage authentication and sessions.") +keyring_app = typer.Typer(help="Manage stored keyring credentials.") + + +def _group_root(ctx: typer.Context) -> None: + """Show subgroup help when invoked without a subcommand.""" + + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + + +app.add_typer( + keyring_app, + name="keyring", + invoke_without_command=True, + callback=_group_root, ) @@ -24,7 +43,7 @@ def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: @app.command("status") -@with_execution_context_options +@with_auth_session_options def auth_status(ctx: typer.Context) -> None: """Show the current authentication and session status.""" @@ -119,7 +138,7 @@ def auth_status(ctx: typer.Context) -> None: @app.command("login") -@with_execution_context_options +@with_auth_login_options def auth_login(ctx: typer.Context) -> None: """Authenticate and persist a usable session.""" @@ -153,7 +172,7 @@ def auth_login(ctx: typer.Context) -> None: @app.command("logout") -@with_execution_context_options +@with_auth_session_options def auth_logout( ctx: typer.Context, keep_trusted: bool = typer.Option( @@ -231,3 +250,27 @@ def auth_logout( ) else: state.console.print("Cleared local session; remote logout was not confirmed.") + + +@keyring_app.command("delete") +@with_keyring_delete_options +def auth_keyring_delete(ctx: typer.Context) -> None: + """Delete a stored keyring password.""" + + state = get_state(ctx) + if not state.has_explicit_username: + raise CLIAbort("The --username option is required for auth keyring delete.") + + deleted = state.delete_keyring_password(state.username) + payload = { + "account_name": state.username, + "stored_password_removed": deleted, + } + if state.json_output: + state.write_json(payload) + return + state.console.print( + "Deleted stored password from keyring." + if deleted + else "No stored password was found for that account." + ) diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py index 8738fb7b..0a6c8ebc 100644 --- a/pyicloud/cli/commands/calendar.py +++ b/pyicloud/cli/commands/calendar.py @@ -9,14 +9,14 @@ from pyicloud.cli.context import get_state, parse_datetime, service_call from pyicloud.cli.normalize import normalize_calendar, normalize_event -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect calendars and events.") @app.command("calendars") -@with_execution_context_options +@with_service_command_options def calendar_calendars(ctx: typer.Context) -> None: """List available calendars.""" @@ -47,7 +47,7 @@ def calendar_calendars(ctx: typer.Context) -> None: @app.command("events") -@with_execution_context_options +@with_service_command_options def calendar_events( ctx: typer.Context, from_dt: Optional[str] = typer.Option(None, "--from", help="Start datetime."), diff --git a/pyicloud/cli/commands/contacts.py b/pyicloud/cli/commands/contacts.py index 0c05d816..908cd9c3 100644 --- a/pyicloud/cli/commands/contacts.py +++ b/pyicloud/cli/commands/contacts.py @@ -8,14 +8,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_contact, normalize_me -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud contacts.") @app.command("list") -@with_execution_context_options +@with_service_command_options def contacts_list( ctx: typer.Context, limit: int = typer.Option(50, "--limit", min=1, help="Maximum contacts to show."), @@ -52,7 +52,7 @@ def contacts_list( @app.command("me") -@with_execution_context_options +@with_service_command_options def contacts_me(ctx: typer.Context) -> None: """Show the signed-in contact card.""" diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index c28de105..201d9b0f 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -8,7 +8,7 @@ from pyicloud.cli.context import get_state, resolve_device, service_call from pyicloud.cli.normalize import normalize_device_details, normalize_device_summary -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_devices_command_options from pyicloud.cli.output import ( console_kv_table, console_table, @@ -20,7 +20,7 @@ @app.command("list") -@with_execution_context_options +@with_devices_command_options def devices_list( ctx: typer.Context, locate: bool = typer.Option( @@ -58,7 +58,7 @@ def devices_list( @app.command("show") -@with_execution_context_options +@with_devices_command_options def devices_show( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -96,7 +96,7 @@ def devices_show( @app.command("sound") -@with_execution_context_options +@with_devices_command_options def devices_sound( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -116,7 +116,7 @@ def devices_sound( @app.command("message") -@with_execution_context_options +@with_devices_command_options def devices_message( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -143,7 +143,7 @@ def devices_message( @app.command("lost-mode") -@with_execution_context_options +@with_devices_command_options def devices_lost_mode( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -174,7 +174,7 @@ def devices_lost_mode( @app.command("erase") -@with_execution_context_options +@with_devices_command_options def devices_erase( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -197,7 +197,7 @@ def devices_erase( @app.command("export") -@with_execution_context_options +@with_devices_command_options def devices_export( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), diff --git a/pyicloud/cli/commands/drive.py b/pyicloud/cli/commands/drive.py index 899244cf..242d7645 100644 --- a/pyicloud/cli/commands/drive.py +++ b/pyicloud/cli/commands/drive.py @@ -14,14 +14,14 @@ write_response_to_path, ) from pyicloud.cli.normalize import normalize_drive_node -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Drive files.") @app.command("list") -@with_execution_context_options +@with_service_command_options def drive_list( ctx: typer.Context, path: str = typer.Argument("/", help="Drive path, for example /Documents."), @@ -73,7 +73,7 @@ def drive_list( @app.command("download") -@with_execution_context_options +@with_service_command_options def drive_download( ctx: typer.Context, path: str = typer.Argument(..., help="Drive path to the file."), diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index 8d6589c9..7e1abe0d 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -6,14 +6,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_alias -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Manage Hide My Email aliases.") @app.command("list") -@with_execution_context_options +@with_service_command_options def hidemyemail_list(ctx: typer.Context) -> None: """List Hide My Email aliases.""" @@ -39,7 +39,7 @@ def hidemyemail_list(ctx: typer.Context) -> None: @app.command("generate") -@with_execution_context_options +@with_service_command_options def hidemyemail_generate(ctx: typer.Context) -> None: """Generate a new relay address.""" @@ -54,7 +54,7 @@ def hidemyemail_generate(ctx: typer.Context) -> None: @app.command("reserve") -@with_execution_context_options +@with_service_command_options def hidemyemail_reserve( ctx: typer.Context, email: str = typer.Argument(...), @@ -76,7 +76,7 @@ def hidemyemail_reserve( @app.command("update") -@with_execution_context_options +@with_service_command_options def hidemyemail_update( ctx: typer.Context, anonymous_id: str = typer.Argument(...), @@ -98,7 +98,7 @@ def hidemyemail_update( @app.command("deactivate") -@with_execution_context_options +@with_service_command_options def hidemyemail_deactivate( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: @@ -116,7 +116,7 @@ def hidemyemail_deactivate( @app.command("reactivate") -@with_execution_context_options +@with_service_command_options def hidemyemail_reactivate( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: @@ -134,7 +134,7 @@ def hidemyemail_reactivate( @app.command("delete") -@with_execution_context_options +@with_service_command_options def hidemyemail_delete( ctx: typer.Context, anonymous_id: str = typer.Argument(...) ) -> None: diff --git a/pyicloud/cli/commands/notes.py b/pyicloud/cli/commands/notes.py index f277079b..fcceacc0 100644 --- a/pyicloud/cli/commands/notes.py +++ b/pyicloud/cli/commands/notes.py @@ -10,14 +10,14 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import select_recent_notes -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect, render, and export Notes.") @app.command("recent") -@with_execution_context_options +@with_service_command_options def notes_recent( ctx: typer.Context, limit: int = typer.Option(10, "--limit", min=1, help="Maximum notes to show."), @@ -48,7 +48,7 @@ def notes_recent( @app.command("folders") -@with_execution_context_options +@with_service_command_options def notes_folders(ctx: typer.Context) -> None: """List note folders.""" @@ -68,7 +68,7 @@ def notes_folders(ctx: typer.Context) -> None: @app.command("list") -@with_execution_context_options +@with_service_command_options def notes_list( ctx: typer.Context, folder_id: Optional[str] = typer.Option(None, "--folder-id", help="Folder id."), @@ -107,7 +107,7 @@ def notes_list( @app.command("get") -@with_execution_context_options +@with_service_command_options def notes_get( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -138,7 +138,7 @@ def notes_get( @app.command("render") -@with_execution_context_options +@with_service_command_options def notes_render( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -164,7 +164,7 @@ def notes_render( @app.command("export") -@with_execution_context_options +@with_service_command_options def notes_export( ctx: typer.Context, note_id: str = typer.Argument(...), @@ -198,7 +198,7 @@ def notes_export( @app.command("changes") -@with_execution_context_options +@with_service_command_options def notes_changes( ctx: typer.Context, since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), diff --git a/pyicloud/cli/commands/photos.py b/pyicloud/cli/commands/photos.py index 82603b61..dc1b06ad 100644 --- a/pyicloud/cli/commands/photos.py +++ b/pyicloud/cli/commands/photos.py @@ -10,14 +10,14 @@ from pyicloud.cli.context import CLIAbort, get_state, service_call from pyicloud.cli.normalize import normalize_album, normalize_photo -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Photos.") @app.command("albums") -@with_execution_context_options +@with_service_command_options def photos_albums(ctx: typer.Context) -> None: """List photo albums.""" @@ -38,7 +38,7 @@ def photos_albums(ctx: typer.Context) -> None: @app.command("list") -@with_execution_context_options +@with_service_command_options def photos_list( ctx: typer.Context, album: Optional[str] = typer.Option( @@ -78,7 +78,7 @@ def photos_list( @app.command("download") -@with_execution_context_options +@with_service_command_options def photos_download( ctx: typer.Context, photo_id: str = typer.Argument(..., help="Photo asset id."), diff --git a/pyicloud/cli/commands/reminders.py b/pyicloud/cli/commands/reminders.py index 2bbfc35c..e2e78c89 100644 --- a/pyicloud/cli/commands/reminders.py +++ b/pyicloud/cli/commands/reminders.py @@ -8,14 +8,14 @@ import typer from pyicloud.cli.context import get_state, parse_datetime, service_call -from pyicloud.cli.options import with_execution_context_options +from pyicloud.cli.options import with_service_command_options from pyicloud.cli.output import console_table, format_color_value app = typer.Typer(help="Inspect and mutate Reminders.") @app.command("lists") -@with_execution_context_options +@with_service_command_options def reminders_lists(ctx: typer.Context) -> None: """List reminder lists.""" @@ -38,7 +38,7 @@ def reminders_lists(ctx: typer.Context) -> None: @app.command("list") -@with_execution_context_options +@with_service_command_options def reminders_list( ctx: typer.Context, list_id: Optional[str] = typer.Option(None, "--list-id", help="List id."), @@ -85,7 +85,7 @@ def reminders_list( @app.command("get") -@with_execution_context_options +@with_service_command_options def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> None: """Get one reminder.""" @@ -103,7 +103,7 @@ def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> @app.command("create") -@with_execution_context_options +@with_service_command_options def reminders_create( ctx: typer.Context, list_id: str = typer.Option(..., "--list-id", help="Target list id."), @@ -137,7 +137,7 @@ def reminders_create( @app.command("set-status") -@with_execution_context_options +@with_service_command_options def reminders_set_status( ctx: typer.Context, reminder_id: str = typer.Argument(...), @@ -157,7 +157,7 @@ def reminders_set_status( @app.command("delete") -@with_execution_context_options +@with_service_command_options def reminders_delete( ctx: typer.Context, reminder_id: str = typer.Argument(...) ) -> None: @@ -174,7 +174,7 @@ def reminders_delete( @app.command("changes") -@with_execution_context_options +@with_service_command_options def reminders_changes( ctx: typer.Context, since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index 3604509b..18a8a723 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -19,10 +19,10 @@ from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable from pyicloud.ssl_context import configurable_ssl_verification -from .account_index import AccountIndexEntry, prune_accounts, remember_account +from .account_index import AccountIndexEntry, load_accounts, prune_accounts, remember_account from .output import OutputFormat, write_json -EXECUTION_CONTEXT_OVERRIDES_META_KEY = "execution_context_overrides" +COMMAND_OPTIONS_META_KEY = "command_options" class CLIAbort(RuntimeError): @@ -50,40 +50,21 @@ def logging_level(self) -> int: @dataclass(frozen=True) -class CLIInvocationDefaults: - """Root-level execution context defaults captured before leaf parsing.""" - - username: str - password: Optional[str] - china_mainland: bool - interactive: bool - delete_from_keyring: bool - accept_terms: bool - with_family: bool - session_dir: Optional[str] - http_proxy: Optional[str] - https_proxy: Optional[str] - no_verify_ssl: bool - log_level: LogLevel - output_format: OutputFormat - - -@dataclass(frozen=True) -class CommandOverrides: - """Leaf command execution-context overrides.""" +class CLICommandOptions: + """Command-local options captured from the leaf command.""" username: Optional[str] = None password: Optional[str] = None china_mainland: Optional[bool] = None - interactive: Optional[bool] = None - accept_terms: Optional[bool] = None - with_family: Optional[bool] = None + interactive: bool = True + accept_terms: bool = False + with_family: bool = False session_dir: Optional[str] = None http_proxy: Optional[str] = None https_proxy: Optional[str] = None - no_verify_ssl: Optional[bool] = None - log_level: Optional[LogLevel] = None - output_format: Optional[OutputFormat] = None + no_verify_ssl: bool = False + log_level: LogLevel = LogLevel.WARNING + output_format: OutputFormat = OutputFormat.TEXT class CLIState: @@ -92,11 +73,10 @@ class CLIState: def __init__( self, *, - username: str, + username: Optional[str], password: Optional[str], - china_mainland: bool, + china_mainland: Optional[bool], interactive: bool, - delete_from_keyring: bool, accept_terms: bool, with_family: bool, session_dir: Optional[str], @@ -106,11 +86,10 @@ def __init__( log_level: LogLevel, output_format: OutputFormat, ) -> None: - self.username = username.strip() + self.username = (username or "").strip() self.password = password self.china_mainland = china_mainland self.interactive = interactive - self.delete_from_keyring = delete_from_keyring self.accept_terms = accept_terms self.with_family = with_family self.session_dir = session_dir @@ -127,72 +106,22 @@ def __init__( self._resolved_username: Optional[str] = self.username or None @classmethod - def from_invocation( - cls, - defaults: CLIInvocationDefaults, - overrides: Optional[CommandOverrides] = None, - ) -> "CLIState": - """Build CLI state from root defaults plus leaf overrides.""" - - overrides = overrides or CommandOverrides() + def from_options(cls, options: CLICommandOptions) -> "CLIState": + """Build CLI state from one leaf command's options.""" + return cls( - username=( - defaults.username if overrides.username is None else overrides.username - ), - password=( - defaults.password if overrides.password is None else overrides.password - ), - china_mainland=( - defaults.china_mainland - if overrides.china_mainland is None - else overrides.china_mainland - ), - interactive=( - defaults.interactive - if overrides.interactive is None - else overrides.interactive - ), - delete_from_keyring=defaults.delete_from_keyring, - accept_terms=( - defaults.accept_terms - if overrides.accept_terms is None - else overrides.accept_terms - ), - with_family=( - defaults.with_family - if overrides.with_family is None - else overrides.with_family - ), - session_dir=( - defaults.session_dir - if overrides.session_dir is None - else overrides.session_dir - ), - http_proxy=( - defaults.http_proxy - if overrides.http_proxy is None - else overrides.http_proxy - ), - https_proxy=( - defaults.https_proxy - if overrides.https_proxy is None - else overrides.https_proxy - ), - no_verify_ssl=( - defaults.no_verify_ssl - if overrides.no_verify_ssl is None - else overrides.no_verify_ssl - ), - log_level=( - defaults.log_level - if overrides.log_level is None - else overrides.log_level - ), - output_format=( - defaults.output_format - if overrides.output_format is None - else overrides.output_format - ), + username=options.username, + password=options.password, + china_mainland=options.china_mainland, + interactive=options.interactive, + accept_terms=options.accept_terms, + with_family=options.with_family, + session_dir=options.session_dir, + http_proxy=options.http_proxy, + https_proxy=options.https_proxy, + no_verify_ssl=options.no_verify_ssl, + log_level=options.log_level, + output_format=options.output_format, ) @property @@ -228,13 +157,6 @@ def write_json(self, payload: Any) -> None: write_json(self.console, payload) - def delete_stored_password(self) -> bool: - """Delete a stored keyring password.""" - - if not self.username: - raise CLIAbort("A username is required with --delete-from-keyring.") - return self.delete_keyring_password(self.username) - def delete_keyring_password(self, username: str) -> bool: """Delete a stored keyring password for a username.""" @@ -269,6 +191,21 @@ def prune_local_accounts(self) -> list[AccountIndexEntry]: return self.local_accounts() + def account_entry(self, username: str) -> Optional[AccountIndexEntry]: + """Return the indexed account entry for a username, if present.""" + + return load_accounts(self.session_root).get(username) + + def resolved_china_mainland(self, username: str) -> Optional[bool]: + """Resolve China mainland mode for an account from command or stored state.""" + + if self.china_mainland is not None: + return self.china_mainland + entry = self.account_entry(username) + if entry is None: + return None + return entry.get("china_mainland") + def remember_account(self, api: PyiCloudService, *, select: bool = True) -> None: """Persist an account entry for later local discovery.""" @@ -277,6 +214,7 @@ def remember_account(self, api: PyiCloudService, *, select: bool = True) -> None username=api.account_name, session_path=api.session.session_path, cookiejar_path=api.session.cookiejar_path, + china_mainland=api.is_china_mainland, keyring_has=self.has_keyring_password, ) if select: @@ -410,7 +348,7 @@ def get_login_api(self) -> PyiCloudService: api = PyiCloudService( apple_id=username, password=password, - china_mainland=self.china_mainland, + china_mainland=self.resolved_china_mainland(username), cookie_directory=self.session_dir, accept_terms=self.accept_terms, with_family=self.with_family, @@ -475,7 +413,7 @@ def build_probe_api(self, username: str) -> PyiCloudService: return PyiCloudService( apple_id=username, password=self.password, - china_mainland=self.china_mainland, + china_mainland=self.resolved_china_mainland(username), cookie_directory=self.session_dir, accept_terms=self.accept_terms, with_family=self.with_family, @@ -530,13 +468,9 @@ def get_state(ctx: typer.Context) -> CLIState: state = root_ctx.obj if isinstance(state, CLIState): return state - if not isinstance(state, CLIInvocationDefaults): - raise RuntimeError("CLI state was not initialized.") - overrides = CommandOverrides( - **ctx.meta.get(EXECUTION_CONTEXT_OVERRIDES_META_KEY, {}) - ) - resolved = CLIState.from_invocation(state, overrides) + options = CLICommandOptions(**ctx.meta.get(COMMAND_OPTIONS_META_KEY, {})) + resolved = CLIState.from_options(options) resolved.open() root_ctx.call_on_close(resolved.close) root_ctx.obj = resolved diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py index d67c2945..4a9a0968 100644 --- a/pyicloud/cli/options.py +++ b/pyicloud/cli/options.py @@ -1,210 +1,285 @@ -"""Shared execution-context options for Typer CLI leaf commands.""" +"""Shared Typer option profiles for CLI leaf commands.""" from __future__ import annotations import inspect from functools import wraps -from typing import Any, Callable, Optional, TypeVar +from typing import Any, Callable, TypeVar import click import typer -from .context import EXECUTION_CONTEXT_OVERRIDES_META_KEY, LogLevel +from .context import COMMAND_OPTIONS_META_KEY, LogLevel from .output import OutputFormat CommandCallback = TypeVar("CommandCallback", bound=Callable[..., Any]) -EXECUTION_CONTEXT_PANEL = "Execution Context" -EXECUTION_CONTEXT_PLACEMENT_HELP = ( - " Can be provided before the command or on the final command." -) + +ACCOUNT_CONTEXT_PANEL = "Account Context" +AUTHENTICATION_PANEL = "Authentication" +NETWORK_PANEL = "Network" +OUTPUT_DIAGNOSTICS_PANEL = "Output & Diagnostics" +DEVICES_PANEL = "Devices" USERNAME_OPTION_HELP = "Apple ID username." -ROOT_USERNAME_OPTION_HELP = ( - USERNAME_OPTION_HELP - + EXECUTION_CONTEXT_PLACEMENT_HELP - + " Optional when a command can infer a single account context." -) PASSWORD_OPTION_HELP = ( - "Apple ID password. If omitted, pyicloud will use the system keyring or prompt interactively." + "Apple ID password. If omitted, pyicloud will use the system keyring or prompt " + "interactively." ) CHINA_MAINLAND_OPTION_HELP = "Use China mainland Apple web service endpoints." INTERACTIVE_OPTION_HELP = "Enable or disable interactive prompts." ACCEPT_TERMS_OPTION_HELP = "Automatically accept pending Apple iCloud web terms." WITH_FAMILY_OPTION_HELP = "Include family devices in Find My device listings." SESSION_DIR_OPTION_HELP = "Directory to store session and cookie files." +HTTP_PROXY_OPTION_HELP = "HTTP proxy URL for requests." +HTTPS_PROXY_OPTION_HELP = "HTTPS proxy URL for requests." NO_VERIFY_SSL_OPTION_HELP = "Disable SSL verification for requests." LOG_LEVEL_OPTION_HELP = "Logging level for pyicloud internals." OUTPUT_FORMAT_OPTION_HELP = "Output format for command results." -ROOT_OUTPUT_FORMAT_OPTION_HELP = ( - OUTPUT_FORMAT_OPTION_HELP + EXECUTION_CONTEXT_PLACEMENT_HELP -) + +PROFILE_ACCOUNT_CONTEXT = "account_context" +PROFILE_AUTHENTICATION = "authentication" +PROFILE_NETWORK = "network" +PROFILE_OUTPUT_DIAGNOSTICS = "output_diagnostics" +PROFILE_DEVICES = "devices" -def _execution_context_parameters() -> list[inspect.Parameter]: - """Return shared final-command execution-context parameters.""" +def _parameter( + name: str, + annotation: Any, + default: Any, +) -> inspect.Parameter: + return inspect.Parameter( + name, + inspect.Parameter.KEYWORD_ONLY, + annotation=annotation, + default=default, + ) + +def _account_context_parameters() -> list[inspect.Parameter]: return [ - inspect.Parameter( + _parameter( "username", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[str], - default=typer.Option( + str | None, + typer.Option( None, "--username", help=USERNAME_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=ACCOUNT_CONTEXT_PANEL, ), ), - inspect.Parameter( + _parameter( + "session_dir", + str | None, + typer.Option( + None, + "--session-dir", + help=SESSION_DIR_OPTION_HELP, + rich_help_panel=ACCOUNT_CONTEXT_PANEL, + ), + ), + ] + + +def _authentication_parameters() -> list[inspect.Parameter]: + return [ + _parameter( "password", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[str], - default=typer.Option( + str | None, + typer.Option( None, "--password", help=PASSWORD_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=AUTHENTICATION_PANEL, ), ), - inspect.Parameter( + _parameter( "china_mainland", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[bool], - default=typer.Option( + bool | None, + typer.Option( None, "--china-mainland", help=CHINA_MAINLAND_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=AUTHENTICATION_PANEL, ), ), - inspect.Parameter( + _parameter( "interactive", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[bool], - default=typer.Option( - None, + bool, + typer.Option( + True, "--interactive/--non-interactive", help=INTERACTIVE_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=AUTHENTICATION_PANEL, ), ), - inspect.Parameter( + _parameter( "accept_terms", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[bool], - default=typer.Option( - None, + bool, + typer.Option( + False, "--accept-terms", help=ACCEPT_TERMS_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=AUTHENTICATION_PANEL, ), ), - inspect.Parameter( - "with_family", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[bool], - default=typer.Option( - None, - "--with-family", - help=WITH_FAMILY_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, - ), - ), - inspect.Parameter( - "session_dir", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[str], - default=typer.Option( - None, - "--session-dir", - help=SESSION_DIR_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, - ), - ), - inspect.Parameter( + ] + + +def _network_parameters() -> list[inspect.Parameter]: + return [ + _parameter( "http_proxy", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[str], - default=typer.Option( + str | None, + typer.Option( None, "--http-proxy", - rich_help_panel=EXECUTION_CONTEXT_PANEL, + help=HTTP_PROXY_OPTION_HELP, + rich_help_panel=NETWORK_PANEL, ), ), - inspect.Parameter( + _parameter( "https_proxy", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[str], - default=typer.Option( + str | None, + typer.Option( None, "--https-proxy", - rich_help_panel=EXECUTION_CONTEXT_PANEL, + help=HTTPS_PROXY_OPTION_HELP, + rich_help_panel=NETWORK_PANEL, ), ), - inspect.Parameter( + _parameter( "no_verify_ssl", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[bool], - default=typer.Option( - None, + bool, + typer.Option( + False, "--no-verify-ssl", help=NO_VERIFY_SSL_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=NETWORK_PANEL, ), ), - inspect.Parameter( + ] + + +def _output_diagnostics_parameters() -> list[inspect.Parameter]: + return [ + _parameter( + "output_format", + OutputFormat, + typer.Option( + OutputFormat.TEXT, + "--format", + case_sensitive=False, + help=OUTPUT_FORMAT_OPTION_HELP, + rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, + ), + ), + _parameter( "log_level", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[LogLevel], - default=typer.Option( - None, + LogLevel, + typer.Option( + LogLevel.WARNING, "--log-level", case_sensitive=False, help=LOG_LEVEL_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, ), ), - inspect.Parameter( - "output_format", - inspect.Parameter.KEYWORD_ONLY, - annotation=Optional[OutputFormat], - default=typer.Option( - None, - "--format", - case_sensitive=False, - help=OUTPUT_FORMAT_OPTION_HELP, - rich_help_panel=EXECUTION_CONTEXT_PANEL, + ] + + +def _device_parameters() -> list[inspect.Parameter]: + return [ + _parameter( + "with_family", + bool, + typer.Option( + False, + "--with-family", + help=WITH_FAMILY_OPTION_HELP, + rich_help_panel=DEVICES_PANEL, ), ), ] -def with_execution_context_options(fn: CommandCallback) -> CommandCallback: - """Inject shared execution-context options onto a leaf command.""" +PROFILE_FACTORIES: dict[str, Callable[[], list[inspect.Parameter]]] = { + PROFILE_ACCOUNT_CONTEXT: _account_context_parameters, + PROFILE_AUTHENTICATION: _authentication_parameters, + PROFILE_NETWORK: _network_parameters, + PROFILE_OUTPUT_DIAGNOSTICS: _output_diagnostics_parameters, + PROFILE_DEVICES: _device_parameters, +} - signature = inspect.signature(fn) - extra_parameters = _execution_context_parameters() - parameter_names = [parameter.name for parameter in extra_parameters] - @wraps(fn) - def wrapper(*args: Any, **kwargs: Any): - ctx = kwargs.get("ctx") - if ctx is None: - ctx = next( - (arg for arg in args if isinstance(arg, click.Context)), - None, - ) - if ctx is None: - raise RuntimeError("CLI context was not provided.") - - overrides = ctx.meta.setdefault(EXECUTION_CONTEXT_OVERRIDES_META_KEY, {}) - for name in parameter_names: - value = kwargs.pop(name, None) - if value is not None: - overrides[name] = value - return fn(*args, **kwargs) - - wrapper.__signature__ = signature.replace( - parameters=list(signature.parameters.values()) + extra_parameters - ) - return wrapper # type: ignore[return-value] +def _profile_parameters(*profiles: str) -> list[inspect.Parameter]: + parameters: list[inspect.Parameter] = [] + seen: set[str] = set() + for profile in profiles: + for parameter in PROFILE_FACTORIES[profile](): + if parameter.name in seen: + continue + seen.add(parameter.name) + parameters.append(parameter) + return parameters + + +def with_option_profiles(*profiles: str) -> Callable[[CommandCallback], CommandCallback]: + """Inject the given command-local option profiles onto a leaf command.""" + + def decorator(fn: CommandCallback) -> CommandCallback: + signature = inspect.signature(fn) + extra_parameters = _profile_parameters(*profiles) + parameter_names = [parameter.name for parameter in extra_parameters] + + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any): + ctx = kwargs.get("ctx") + if ctx is None: + ctx = next( + (arg for arg in args if isinstance(arg, click.Context)), + None, + ) + if ctx is None: + raise RuntimeError("CLI context was not provided.") + + command_options = ctx.meta.setdefault(COMMAND_OPTIONS_META_KEY, {}) + for name in parameter_names: + if name in kwargs: + command_options[name] = kwargs.pop(name) + return fn(*args, **kwargs) + + wrapper.__signature__ = signature.replace( + parameters=list(signature.parameters.values()) + extra_parameters + ) + return wrapper # type: ignore[return-value] + + return decorator + + +with_auth_login_options = with_option_profiles( + PROFILE_ACCOUNT_CONTEXT, + PROFILE_AUTHENTICATION, + PROFILE_NETWORK, + PROFILE_OUTPUT_DIAGNOSTICS, +) +with_auth_session_options = with_option_profiles( + PROFILE_ACCOUNT_CONTEXT, + PROFILE_NETWORK, + PROFILE_OUTPUT_DIAGNOSTICS, +) +with_service_command_options = with_option_profiles( + PROFILE_ACCOUNT_CONTEXT, + PROFILE_NETWORK, + PROFILE_OUTPUT_DIAGNOSTICS, +) +with_devices_command_options = with_option_profiles( + PROFILE_ACCOUNT_CONTEXT, + PROFILE_NETWORK, + PROFILE_OUTPUT_DIAGNOSTICS, + PROFILE_DEVICES, +) +with_keyring_delete_options = with_option_profiles( + PROFILE_ACCOUNT_CONTEXT, + PROFILE_OUTPUT_DIAGNOSTICS, +) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 65750563..cb8a001d 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -360,10 +360,12 @@ def __init__( *, username: str = "user@example.com", session_dir: Optional[Path] = None, + china_mainland: bool = False, ) -> None: self.requires_2fa = False self.requires_2sa = False self.is_trusted_session = True + self.is_china_mainland = china_mainland self.fido2_devices: list[dict[str, Any]] = [] self.trusted_devices: list[dict[str, Any]] = [] self.validate_2fa_code = MagicMock(return_value=True) @@ -522,9 +524,14 @@ def _remember_local_account( *, has_session_file: bool = False, has_cookiejar_file: bool = False, + china_mainland: bool | None = None, keyring_passwords: Optional[set[str]] = None, ) -> FakeAPI: - fake_api = FakeAPI(username=username, session_dir=session_dir) + fake_api = FakeAPI( + username=username, + session_dir=session_dir, + china_mainland=bool(china_mainland), + ) if has_session_file: with open(fake_api.session.session_path, "w", encoding="utf-8"): pass @@ -536,6 +543,7 @@ def _remember_local_account( username=username, session_path=fake_api.session.session_path, cookiejar_path=fake_api.session.cookiejar_path, + china_mainland=china_mainland, keyring_has=lambda candidate: candidate in (keyring_passwords or set()), ) return fake_api @@ -545,21 +553,55 @@ def _invoke( fake_api: FakeAPI, *args: str, username: Optional[str] = "user@example.com", - password: Optional[str] = "secret", - interactive: bool = False, + password: Optional[str] = None, + interactive: Optional[bool] = None, session_dir: Optional[Path] = None, + china_mainland: Optional[bool] = None, + accept_terms: Optional[bool] = None, + with_family: Optional[bool] = None, + output_format: Optional[str] = None, + log_level: Optional[str] = None, + http_proxy: Optional[str] = None, + https_proxy: Optional[str] = None, + no_verify_ssl: bool = False, keyring_passwords: Optional[set[str]] = None, ): runner = _runner() session_dir = session_dir or _unique_session_dir("invoke") - cli_args = [ - *([] if username is None else ["--username", username]), - *([] if password is None else ["--password", password]), - "--session-dir", - str(session_dir), - *([] if interactive else ["--non-interactive"]), - *args, - ] + cli_args = list(args) + command_path = tuple(args[:3]) + supports_auth_login = command_path[:2] == ("auth", "login") + supports_devices = args[:1] == ("devices",) + supports_keyring_delete = command_path[:3] == ("auth", "keyring", "delete") + + if username is not None: + cli_args.extend(["--username", username]) + if session_dir is not None: + cli_args.extend(["--session-dir", str(session_dir)]) + if supports_auth_login and password is None: + password = "secret" + if supports_auth_login and interactive is None: + interactive = False + if supports_auth_login and password is not None: + cli_args.extend(["--password", password]) + if supports_auth_login and interactive is not None: + cli_args.append("--interactive" if interactive else "--non-interactive") + if supports_auth_login and china_mainland: + cli_args.append("--china-mainland") + if supports_auth_login and accept_terms: + cli_args.append("--accept-terms") + if not supports_keyring_delete and http_proxy is not None: + cli_args.extend(["--http-proxy", http_proxy]) + if not supports_keyring_delete and https_proxy is not None: + cli_args.extend(["--https-proxy", https_proxy]) + if not supports_keyring_delete and no_verify_ssl: + cli_args.append("--no-verify-ssl") + if supports_devices and with_family: + cli_args.append("--with-family") + if output_format is not None: + cli_args.extend(["--format", output_format]) + if log_level is not None: + cli_args.extend(["--log-level", log_level]) with ( patch.object(context_module, "PyiCloudService", return_value=fake_api), patch.object( @@ -598,15 +640,17 @@ def _invoke_with_cli_args( def test_root_help() -> None: - """The root command should expose the service subcommands and format option.""" + """The root command should expose only help/completion utilities and subcommands.""" result = _runner().invoke(app, ["--help"]) assert result.exit_code == 0 - assert "--username" in result.stdout - assert "before the command or on the final command" in result.stdout - assert "--format" in result.stdout - assert "--json" not in result.stdout - assert "--debug" not in result.stdout + assert "--username" not in result.stdout + assert "--password" not in result.stdout + assert "--format" not in result.stdout + assert "--session-dir" not in result.stdout + assert "--http-proxy" not in result.stdout + assert "--install-completion" in result.stdout + assert "--show-completion" in result.stdout for command in ( "account", "auth", @@ -663,7 +707,7 @@ def test_bare_group_invocation_shows_help() -> None: def test_leaf_help_includes_execution_context_options() -> None: - """Leaf command help should show shared execution-context options.""" + """Leaf command help should show the command-local options it supports.""" result = _runner().invoke(app, ["account", "summary", "--help"]) @@ -671,6 +715,31 @@ def test_leaf_help_includes_execution_context_options() -> None: assert "--username" in result.stdout assert "--format" in result.stdout assert "--session-dir" in result.stdout + assert "--password" not in result.stdout + assert "--with-family" not in result.stdout + + +def test_auth_login_help_scopes_authentication_options() -> None: + """Auth login help should expose auth-only options on the leaf command.""" + + result = _runner().invoke(app, ["auth", "login", "--help"]) + + assert result.exit_code == 0 + assert "--username" in result.stdout + assert "--password" in result.stdout + assert "--china-mainland" in result.stdout + assert "--interactive" in result.stdout + assert "--accept-terms" in result.stdout + assert "--with-family" not in result.stdout + + +def test_devices_help_scopes_device_options() -> None: + """Devices help should expose device-specific options on device commands only.""" + + result = _runner().invoke(app, ["devices", "list", "--help"]) + + assert result.exit_code == 0 + assert "--with-family" in result.stdout def test_account_summary_command() -> None: @@ -683,9 +752,9 @@ def test_account_summary_command() -> None: def test_format_option_outputs_json() -> None: - """The root format option should support machine-readable JSON.""" + """Leaf --format should support machine-readable JSON.""" - result = _invoke(FakeAPI(), "--format", "json", "account", "summary") + result = _invoke(FakeAPI(), "account", "summary", output_format="json") payload = json.loads(result.stdout) assert result.exit_code == 0 assert payload["account_name"] == "user@example.com" @@ -699,15 +768,12 @@ def test_command_local_format_option_outputs_json() -> None: result = _invoke_with_cli_args( FakeAPI(session_dir=session_dir), [ + "account", + "summary", "--username", "user@example.com", - "--password", - "secret", "--session-dir", str(session_dir), - "--non-interactive", - "account", - "summary", "--format", "json", ], @@ -718,15 +784,29 @@ def test_command_local_format_option_outputs_json() -> None: assert payload["account_name"] == "user@example.com" -def test_leaf_execution_context_overrides_root_values() -> None: - """Leaf execution-context options should take precedence over root values.""" +def test_old_root_execution_options_fail_cleanly() -> None: + """Root execution options should no longer be accepted.""" + + for cli_args in ( + ["--username", "user@example.com", "auth", "login"], + ["--password", "secret", "auth", "login"], + ["--session-dir", "/tmp/pyicloud", "account", "summary"], + ["--format", "json", "account", "summary"], + ["--delete-from-keyring"], + ): + result = _runner().invoke(app, cli_args) + assert result.exit_code != 0 + assert "No such option" in (result.stdout + result.stderr) - session_dir = _unique_session_dir("leaf-precedence") + +def test_auth_login_accepts_command_local_username() -> None: + """Auth login should accept --username after the final subcommand.""" + + session_dir = _unique_session_dir("leaf-username") fake_api = FakeAPI(username="leaf@example.com", session_dir=session_dir) - def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: assert apple_id == "leaf@example.com" - assert kwargs["cookie_directory"] == str(session_dir) return fake_api with ( @@ -742,41 +822,31 @@ def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: result = _runner().invoke( app, [ - "--username", - "root@example.com", - "--password", - "root-secret", - "--session-dir", - "/tmp/root-session", - "--format", - "json", - "--non-interactive", "auth", "login", "--username", "leaf@example.com", "--password", - "leaf-secret", + "secret", "--session-dir", str(session_dir), - "--format", - "text", + "--non-interactive", ], ) assert result.exit_code == 0 - assert "Authenticated session is ready." in result.stdout - assert result.stdout.lstrip()[0] != "{" + assert "leaf@example.com" in result.stdout -def test_auth_login_accepts_command_local_username() -> None: - """Auth login should accept --username after the final subcommand.""" +def test_leaf_session_dir_option_is_used_for_service_commands() -> None: + """Leaf --session-dir should be honored by service commands.""" - session_dir = _unique_session_dir("leaf-username") - fake_api = FakeAPI(username="leaf@example.com", session_dir=session_dir) + session_dir = _unique_session_dir("leaf-session-dir") + fake_api = FakeAPI(session_dir=session_dir) - def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: - assert apple_id == "leaf@example.com" + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "user@example.com" + assert kwargs["cookie_directory"] == str(session_dir) return fake_api with ( @@ -792,32 +862,49 @@ def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: result = _runner().invoke( app, [ - "--password", - "secret", + "account", + "summary", + "--username", + "user@example.com", "--session-dir", str(session_dir), - "--non-interactive", - "auth", - "login", - "--username", - "leaf@example.com", + "--format", + "text", ], ) assert result.exit_code == 0 - assert "leaf@example.com" in result.stdout + assert "Account: user@example.com" in result.stdout -def test_leaf_session_dir_option_is_used_for_service_commands() -> None: - """Leaf --session-dir should be honored by service commands.""" +def test_china_mainland_is_login_only() -> None: + """China mainland selection should only be accepted on auth login.""" - session_dir = _unique_session_dir("leaf-session-dir") - fake_api = FakeAPI(session_dir=session_dir) + status_result = _runner().invoke(app, ["auth", "status", "--china-mainland"]) + service_result = _runner().invoke(app, ["account", "summary", "--china-mainland"]) - def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: - assert apple_id == "user@example.com" - assert kwargs["cookie_directory"] == str(session_dir) - return fake_api + assert status_result.exit_code != 0 + assert "No such option: --china-mainland" in (status_result.stdout + status_result.stderr) + assert service_result.exit_code != 0 + assert "No such option: --china-mainland" in ( + service_result.stdout + service_result.stderr + ) + + +def test_auth_login_persists_china_mainland_metadata() -> None: + """Auth login should persist China mainland metadata for later commands.""" + + session_dir = _unique_session_dir("china-mainland") + + def fake_service(*, apple_id: str, china_mainland: Any, **_kwargs: Any) -> FakeAPI: + if apple_id == "cn@example.com": + assert china_mainland is True + return FakeAPI( + username="cn@example.com", + session_dir=session_dir, + china_mainland=True, + ) + raise AssertionError("Unexpected account") with ( patch.object(context_module, "PyiCloudService", side_effect=fake_service), @@ -829,23 +916,71 @@ def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: context_module.utils, "password_exists_in_keyring", return_value=False ), ): - result = _runner().invoke( + login_result = _runner().invoke( app, [ + "auth", + "login", "--username", - "user@example.com", + "cn@example.com", "--password", "secret", + "--session-dir", + str(session_dir), + "--china-mainland", "--non-interactive", + ], + ) + + assert login_result.exit_code == 0 + assert account_index_module.load_accounts(session_dir)["cn@example.com"][ + "china_mainland" + ] is True + + +def test_persisted_china_mainland_metadata_is_used_for_service_commands() -> None: + """Stored China mainland metadata should be reused by later service probes.""" + + session_dir = _unique_session_dir("china-mainland-probe") + _remember_local_account( + session_dir, + "cn@example.com", + has_session_file=True, + china_mainland=True, + ) + + def fake_service(*, apple_id: str, china_mainland: Any, **_kwargs: Any) -> FakeAPI: + assert apple_id == "cn@example.com" + assert china_mainland is True + return FakeAPI( + username="cn@example.com", + session_dir=session_dir, + china_mainland=True, + ) + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=False + ), + ): + result = _runner().invoke( + app, + [ "account", "summary", + "--username", + "cn@example.com", "--session-dir", str(session_dir), ], ) assert result.exit_code == 0 - assert "Account: user@example.com" in result.stdout + assert "Account: cn@example.com" in result.stdout def test_default_log_level_is_warning() -> None: @@ -865,7 +1000,7 @@ def test_no_local_accounts_require_username() -> None: context_module, "configurable_ssl_verification", return_value=nullcontext() ): result = _runner().invoke( - app, ["--session-dir", str(session_dir), "account", "summary"] + app, ["account", "summary", "--session-dir", str(session_dir)] ) assert result.exit_code != 0 assert ( @@ -875,8 +1010,8 @@ def test_no_local_accounts_require_username() -> None: ) -def test_delete_from_keyring() -> None: - """The keyring delete path should work without invoking a subcommand.""" +def test_auth_keyring_delete() -> None: + """The keyring delete subcommand should delete stored credentials.""" session_dir = _unique_session_dir("delete-keyring") _remember_local_account( @@ -900,11 +1035,13 @@ def test_delete_from_keyring() -> None: result = _runner().invoke( app, [ + "auth", + "keyring", + "delete", "--username", "user@example.com", "--session-dir", str(session_dir), - "--delete-from-keyring", ], ) assert result.exit_code == 0 @@ -913,17 +1050,19 @@ def test_delete_from_keyring() -> None: assert account_index_module.load_accounts(session_dir) == {} -def test_delete_from_keyring_remains_root_only() -> None: - """Utility flags like --delete-from-keyring should remain root-only.""" +def test_auth_keyring_delete_requires_explicit_username() -> None: + """Deleting stored credentials should require an explicit username.""" result = _runner().invoke( app, - ["auth", "login", "--delete-from-keyring"], + ["auth", "keyring", "delete"], ) assert result.exit_code != 0 - combined_output = result.stdout + result.stderr - assert "No such option: --delete-from-keyring" in combined_output + assert ( + result.exception.args[0] + == "The --username option is required for auth keyring delete." + ) def test_auth_status_probe_is_non_interactive() -> None: @@ -954,7 +1093,7 @@ def test_auth_status_probe_is_non_interactive() -> None: ): result = _runner().invoke( app, - ["--session-dir", str(session_dir), "--non-interactive", "auth", "status"], + ["auth", "status", "--session-dir", str(session_dir)], ) assert result.exit_code == 0 assert "You are not logged into any iCloud accounts." in result.stdout @@ -988,8 +1127,8 @@ def test_auth_login_and_status_commands() -> None: """Auth status and login should expose stable text and JSON payloads.""" fake_api = FakeAPI() - status_result = _invoke(fake_api, "--format", "json", "auth", "status") - login_result = _invoke(fake_api, "--format", "json", "auth", "login") + status_result = _invoke(fake_api, "auth", "status", output_format="json") + login_result = _invoke(fake_api, "auth", "login", output_format="json") status_payload = json.loads(status_result.stdout) login_payload = json.loads(login_result.stdout) @@ -1127,11 +1266,11 @@ def test_multiple_local_accounts_require_explicit_username_for_auth_login() -> N result = _runner().invoke( app, [ + "auth", + "login", "--session-dir", str(session_dir), "--non-interactive", - "auth", - "login", ], ) @@ -1175,11 +1314,10 @@ def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: result = _runner().invoke( app, [ - "--session-dir", - str(session_dir), - "--non-interactive", "account", "summary", + "--session-dir", + str(session_dir), ], ) @@ -1280,7 +1418,13 @@ def test_auth_login_non_interactive_requires_credentials() -> None: ): result = _runner().invoke( app, - ["--username", "user@example.com", "--non-interactive", "auth", "login"], + [ + "auth", + "login", + "--username", + "user@example.com", + "--non-interactive", + ], ) assert result.exit_code != 0 assert "No password supplied and no stored password was found." in str( @@ -1301,13 +1445,12 @@ def invoke_logout(*args: str, failing_api: Optional[FakeAPI] = None): ) return _invoke( failing_api or FakeAPI(session_dir=session_dir), - "--format", - "json", "auth", "logout", *args, username=None, session_dir=session_dir, + output_format="json", keyring_passwords={"user@example.com"}, ) @@ -1384,13 +1527,12 @@ def test_auth_logout_remove_keyring_is_explicit() -> None: ) as delete_password: result = _invoke( FakeAPI(session_dir=session_dir), - "--format", - "json", "auth", "logout", "--remove-keyring", username=None, session_dir=session_dir, + output_format="json", keyring_passwords={"user@example.com"}, ) @@ -1433,7 +1575,12 @@ def test_devices_list_and_show_commands() -> None: list_result = _invoke(fake_api, "devices", "list", "--locate") show_result = _invoke(fake_api, "devices", "show", "device-1") raw_result = _invoke( - fake_api, "--format", "json", "devices", "show", "device-1", "--raw" + fake_api, + "devices", + "show", + "device-1", + "--raw", + output_format="json", ) assert list_result.exit_code == 0 assert "Jacob's iPhone" in list_result.stdout @@ -1451,13 +1598,12 @@ def test_devices_mutations_and_export() -> None: export_path.parent.mkdir(parents=True, exist_ok=True) sound_result = _invoke( fake_api, - "--format", - "json", "devices", "sound", "device-1", "--subject", "Ping", + output_format="json", ) silent_result = _invoke( fake_api, @@ -1481,13 +1627,12 @@ def test_devices_mutations_and_export() -> None: ) export_result = _invoke( fake_api, - "--format", - "json", "devices", "export", "device-1", "--output", str(export_path), + output_format="json", ) assert sound_result.exit_code == 0 assert json.loads(sound_result.stdout)["subject"] == "Ping" @@ -1537,13 +1682,12 @@ def test_drive_and_photos_commands() -> None: ) json_drive_result = _invoke( fake_api, - "--format", - "json", "drive", "download", "/report.txt", "--output", str(json_output_path), + output_format="json", ) assert drive_result.exit_code == 0 assert "report.txt" in drive_result.stdout @@ -1573,8 +1717,6 @@ def test_reminders_commands() -> None: list_result = _invoke(fake_api, "reminders", "list") create_result = _invoke( fake_api, - "--format", - "json", "reminders", "create", "--list-id", @@ -1583,6 +1725,7 @@ def test_reminders_commands() -> None: "New task", "--priority", "5", + output_format="json", ) assert lists_result.exit_code == 0 assert "blue (#007AFF)" in lists_result.stdout @@ -1602,15 +1745,20 @@ def test_notes_commands() -> None: include_deleted_result = _invoke( fake_api, "notes", "recent", "--limit", "1", "--include-deleted" ) - render_result = _invoke(fake_api, "--format", "json", "notes", "render", "note-1") + render_result = _invoke( + fake_api, + "notes", + "render", + "note-1", + output_format="json", + ) export_result = _invoke( fake_api, - "--format", - "json", "notes", "export", "note-1", str(output_dir), + output_format="json", ) assert recent_result.exit_code == 0 assert "Daily Plan" in recent_result.stdout From d789772a5d72a5868d7a64e093429724602bba5f Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 01:10:36 +0100 Subject: [PATCH 07/18] Refine auth status text output --- pyicloud/cli/commands/auth.py | 63 +++++++++++++++++++++-------------- tests/test_cmdline.py | 35 +++++++++++++++++++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index 8f3c9106..7546199d 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -32,6 +32,41 @@ def _group_root(ctx: typer.Context) -> None: ) +def _storage_path_display(path: object, exists: object) -> object: + """Render a canonical storage path with an inline missing marker for text output.""" + + if exists: + return path + return f"{path} (missing)" + + +def _auth_status_rows(payload: dict[str, object]) -> list[tuple[str, object]]: + """Build text-mode rows for one auth status payload.""" + + return [ + ("Account", payload["account_name"]), + ("Authenticated", payload["authenticated"]), + ("Trusted Session", payload["trusted_session"]), + ("Requires 2FA", payload["requires_2fa"]), + ("Requires 2SA", payload["requires_2sa"]), + ("Password in Keyring", payload["has_keyring_password"]), + ( + "Session File", + _storage_path_display( + payload["session_path"], + payload["has_session_file"], + ), + ), + ( + "Cookie Jar", + _storage_path_display( + payload["cookiejar_path"], + payload["has_cookiejar_file"], + ), + ), + ] + + def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: payload: dict[str, object] = { "account_name": api.account_name, @@ -69,18 +104,7 @@ def auth_status(ctx: typer.Context) -> None: state.console.print( console_kv_table( "Auth Status", - [ - ("Account", payload["account_name"]), - ("Authenticated", payload["authenticated"]), - ("Trusted Session", payload["trusted_session"]), - ("Requires 2FA", payload["requires_2fa"]), - ("Requires 2SA", payload["requires_2sa"]), - ("Stored Password", payload["has_keyring_password"]), - ("Session File", payload["session_path"]), - ("Session File Exists", payload["has_session_file"]), - ("Cookie Jar", payload["cookiejar_path"]), - ("Cookie Jar Exists", payload["has_cookiejar_file"]), - ], + _auth_status_rows(payload), ) ) return @@ -90,7 +114,7 @@ def auth_status(ctx: typer.Context) -> None: [ "Account", "Trusted Session", - "Stored Password", + "Password in Keyring", "Session File Exists", "Cookie Jar Exists", ], @@ -121,18 +145,7 @@ def auth_status(ctx: typer.Context) -> None: state.console.print( console_kv_table( "Auth Status", - [ - ("Account", payload["account_name"]), - ("Authenticated", payload["authenticated"]), - ("Trusted Session", payload["trusted_session"]), - ("Requires 2FA", payload["requires_2fa"]), - ("Requires 2SA", payload["requires_2sa"]), - ("Stored Password", payload["has_keyring_password"]), - ("Session File", payload["session_path"]), - ("Session File Exists", payload["has_session_file"]), - ("Cookie Jar", payload["cookiejar_path"]), - ("Cookie Jar Exists", payload["has_cookiejar_file"]), - ], + _auth_status_rows(payload), ) ) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index cb8a001d..0b3f5376 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -1123,6 +1123,41 @@ def test_auth_status_without_username_ignores_keyring_only_accounts() -> None: assert "user@example.com" not in result.stdout +def test_auth_status_explicit_username_marks_missing_storage_inline() -> None: + """Text auth status should inline missing storage markers instead of extra boolean rows.""" + + session_dir = _unique_session_dir("status-missing-storage") + fake_api = _remember_local_account( + session_dir, + "user@example.com", + keyring_passwords={"user@example.com"}, + ) + fake_api.get_auth_status.return_value = { + "authenticated": False, + "trusted_session": False, + "requires_2fa": False, + "requires_2sa": False, + } + + result = _invoke( + fake_api, + "auth", + "status", + username="user@example.com", + session_dir=session_dir, + keyring_passwords={"user@example.com"}, + ) + + assert result.exit_code == 0 + assert "Password in Keyring" in result.stdout + assert "Stored Password" not in result.stdout + assert "Session File" in result.stdout + assert "Cookie Jar" in result.stdout + assert result.stdout.count("(missing)") == 2 + assert "Session File Exists" not in result.stdout + assert "Cookie Jar Exists" not in result.stdout + + def test_auth_login_and_status_commands() -> None: """Auth status and login should expose stable text and JSON payloads.""" From 13daec924c1a031372ff90be55a9ee4f376b2c9a Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 01:24:20 +0100 Subject: [PATCH 08/18] Add subtle color to CLI tables --- pyicloud/cli/output.py | 24 +++++++++++++++++++++--- tests/test_output.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/test_output.py diff --git a/pyicloud/cli/output.py b/pyicloud/cli/output.py index 3a85ea8a..77256147 100644 --- a/pyicloud/cli/output.py +++ b/pyicloud/cli/output.py @@ -13,6 +13,12 @@ from rich.console import Console from rich.table import Table +TABLE_TITLE_STYLE = "bold bright_cyan" +TABLE_HEADER_STYLE = "bold bright_cyan" +TABLE_BORDER_STYLE = None +TABLE_KEY_STYLE = "bold bright_white" +TABLE_ROW_STYLES: tuple[str, ...] = () + class OutputFormat(str, Enum): """Supported output formats.""" @@ -78,7 +84,13 @@ def console_table( ) -> Table: """Build a simple rich table.""" - table = Table(title=title) + table = Table( + title=title, + title_style=TABLE_TITLE_STYLE, + header_style=TABLE_HEADER_STYLE, + border_style=TABLE_BORDER_STYLE, + row_styles=list(TABLE_ROW_STYLES), + ) for column in columns: table.add_column(column) for row in rows: @@ -89,8 +101,14 @@ def console_table( def console_kv_table(title: str, rows: Iterable[tuple[str, Any]]) -> Table: """Build a two-column key/value table.""" - table = Table(title=title) - table.add_column("Field") + table = Table( + title=title, + title_style=TABLE_TITLE_STYLE, + header_style=TABLE_HEADER_STYLE, + border_style=TABLE_BORDER_STYLE, + row_styles=list(TABLE_ROW_STYLES), + ) + table.add_column("Field", style=TABLE_KEY_STYLE) table.add_column("Value") for key, value in rows: table.add_row(key, "" if value is None else str(value)) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..915c11be --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,36 @@ +"""Tests for CLI output helpers.""" + +from pyicloud.cli.output import ( + TABLE_BORDER_STYLE, + TABLE_HEADER_STYLE, + TABLE_KEY_STYLE, + TABLE_ROW_STYLES, + TABLE_TITLE_STYLE, + console_kv_table, + console_table, +) + + +def test_console_table_uses_shared_styles() -> None: + """console_table should apply the shared table palette.""" + + table = console_table("Devices", ["ID", "Name"], [("device-1", "Phone")]) + + assert table.title == "Devices" + assert table.title_style == TABLE_TITLE_STYLE + assert table.header_style == TABLE_HEADER_STYLE + assert table.border_style == TABLE_BORDER_STYLE + assert tuple(table.row_styles) == TABLE_ROW_STYLES + + +def test_console_kv_table_styles_key_column() -> None: + """console_kv_table should style the key column consistently.""" + + table = console_kv_table("Auth Status", [("Account", "user@example.com")]) + + assert table.title == "Auth Status" + assert table.title_style == TABLE_TITLE_STYLE + assert table.header_style == TABLE_HEADER_STYLE + assert table.border_style == TABLE_BORDER_STYLE + assert tuple(table.row_styles) == TABLE_ROW_STYLES + assert table.columns[0].style == TABLE_KEY_STYLE From 82793850b796dfe52e83f49f569227bb2a9b9504 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 10:56:45 +0100 Subject: [PATCH 09/18] Demote internal warning logs --- pyicloud/base.py | 2 +- pyicloud/session.py | 5 +++-- tests/test_base.py | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pyicloud/base.py b/pyicloud/base.py index 2db001f7..51f86efe 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -781,7 +781,7 @@ def _request_pcs_for_service(self, app_name: str) -> None: _check_pcs_resp: dict[str, Any] = self._check_pcs_consent() if not _check_pcs_resp.get("isICDRSDisabled", False): - LOGGER.warning("ICDRS is not disabled") + LOGGER.debug("Skipping PCS request because Apple reports ICDRS is enabled") return if not _check_pcs_resp.get("isDeviceConsentedForPCS", True): diff --git a/pyicloud/session.py b/pyicloud/session.py index fe3d7a4c..235f25b0 100644 --- a/pyicloud/session.py +++ b/pyicloud/session.py @@ -284,8 +284,9 @@ def _decode_json_response(self, response: Response) -> None: self._raise_error(response, code, reason) except JSONDecodeError: - self.logger.warning( - "Failed to parse response with JSON mimetype: %s", response.text + self.logger.debug( + "Failed to parse response body as JSON despite JSON mimetype; status=%s", + getattr(response, "status_code", "unknown"), ) def _raise_error( diff --git a/tests/test_base.py b/tests/test_base.py index 5638e875..d0d39015 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -679,7 +679,9 @@ def test_request_pcs_for_service_icdrs_not_disabled( with patch("pyicloud.base.LOGGER", mock_logger): pyicloud_service._send_pcs_request = MagicMock() pyicloud_service._request_pcs_for_service("photos") - mock_logger.warning.assert_called_once_with("ICDRS is not disabled") + mock_logger.debug.assert_any_call( + "Skipping PCS request because Apple reports ICDRS is enabled" + ) pyicloud_service._send_pcs_request.assert_not_called() From bc098f7620fb6fcf6510d35a28c94f90156349d2 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 11:50:33 +0100 Subject: [PATCH 10/18] Trim CLI extraction to upstream scope --- README.md | 84 +++++----- pyicloud/cli/app.py | 6 - pyicloud/cli/commands/notes.py | 233 --------------------------- pyicloud/cli/commands/reminders.py | 205 ------------------------ pyicloud/cli/normalize.py | 20 --- pyicloud/cli/output.py | 34 ---- pyproject.toml | 2 +- requirements.txt | 1 + tests/test_cmdline.py | 243 +---------------------------- 9 files changed, 50 insertions(+), 778 deletions(-) delete mode 100644 pyicloud/cli/commands/notes.py delete mode 100644 pyicloud/cli/commands/reminders.py diff --git a/README.md b/README.md index baa8ec06..29eccbef 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Library version](https://img.shields.io/pypi/v/pyicloud)](https://pypi.org/project/pyicloud) [![Supported versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Ftimlaing%2Fpyicloud%2Fmain%2Fpyproject.toml)](https://pypi.org/project/pyicloud) [![Downloads](https://pepy.tech/badge/pyicloud)](https://pypi.org/project/pyicloud) -[![Formatted with Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](ttps://pypi.python.org/pypi/ruff) +[![Formatted with Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://pypi.python.org/pypi/ruff) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) @@ -18,7 +18,7 @@ [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) PyiCloud is a module which allows pythonistas to interact with iCloud -webservices. It\'s powered by the fantastic +webservices. It's powered by the fantastic [requests](https://github.com/kennethreitz/requests) HTTP library. At its core, PyiCloud connects to the iCloud web application using your username and password, then performs regular queries against its API. @@ -27,6 +27,16 @@ At its core, PyiCloud connects to the iCloud web application using your username For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/nru3was4hk) +## Installation + +Install the library and CLI with: + +```console +$ pip install pyicloud +``` + +This installs the `icloud` command line interface alongside the Python package. + ## Authentication Authentication without using a saved password is as simple as passing your username and password to the `PyiCloudService` class: @@ -47,19 +57,26 @@ from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password', china_mainland=True) ``` -If you plan to use this as a daemon / service to keep the connection alive with Apple thus reducing the volume of notification emails. -A refresh interval can be configured (default = 5 minutes). +If you plan to use this as a daemon or long-running service to keep the +connection alive with Apple, a refresh interval can be configured +(default: 5 minutes). ```python from pyicloud import PyiCloudService -api = PyiCloudService('jappleseed@apple.com', 'password', refresh_interval=60) # 1 minute refresh + +api = PyiCloudService( + 'jappleseed@apple.com', + 'password', + refresh_interval=60, # 1 minute refresh +) api.devices ``` +## Command-Line Interface + The `icloud` command line interface is organized around top-level -subcommands such as `account`, `auth`, `devices`, `calendar`, -`contacts`, `drive`, `photos`, `hidemyemail`, `reminders`, and -`notes`. +subcommands such as `auth`, `account`, `devices`, `calendar`, +`contacts`, `drive`, `photos`, and `hidemyemail`. Command options belong on the final command that uses them. For example: @@ -81,41 +98,36 @@ Save password in keyring? (y/N) If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or -instantiating the `PyiCloudService` class for the username you stored -the password for. +instantiating the `PyiCloudService` class for that username. + +```python +api = PyiCloudService('jappleseed@apple.com') +``` -Examples: +CLI examples: ```console $ icloud auth status -$ icloud auth login $ icloud auth login --username jappleseed@apple.com $ icloud auth login --username jappleseed@apple.com --china-mainland $ icloud auth login --username jappleseed@apple.com --accept-terms -$ icloud auth logout -$ icloud auth logout --keep-trusted -$ icloud auth logout --all-sessions -$ icloud auth logout --keep-trusted --all-sessions -$ icloud auth logout --remove-keyring -$ icloud auth keyring delete --username jappleseed@apple.com $ icloud account summary $ icloud account summary --format json $ icloud devices list --locate $ icloud devices list --with-family -$ icloud devices list --session-dir /tmp/pyicloud-test --format json -$ icloud devices show "Jacob's iPhone" -$ icloud devices export "Jacob's iPhone" --output ./iphone.json +$ icloud devices show "Example iPhone" +$ icloud devices export "Example iPhone" --output ./iphone.json $ icloud calendar events --username jappleseed@apple.com --period week $ icloud contacts me --username jappleseed@apple.com $ icloud drive list /Documents --username jappleseed@apple.com $ icloud photos albums --username jappleseed@apple.com $ icloud hidemyemail list --username jappleseed@apple.com -$ icloud reminders lists --username jappleseed@apple.com -$ icloud notes recent --username jappleseed@apple.com --limit 5 -``` - -```python -api = PyiCloudService('jappleseed@apple.com') +$ icloud auth logout +$ icloud auth logout --keep-trusted +$ icloud auth logout --all-sessions +$ icloud auth logout --keep-trusted --all-sessions +$ icloud auth logout --remove-keyring +$ icloud auth keyring delete --username jappleseed@apple.com ``` If you would like to delete a password stored in your system keyring, @@ -155,24 +167,14 @@ but keeps the stored password. Use `icloud auth logout --remove-keyring` or `icloud auth keyring delete --username ` if you also want to forget the saved password. -Migration notes for the previous Find My-focused CLI: - -- `--list` now maps to `icloud devices list` -- `--llist` now maps to `icloud devices show DEVICE --raw` -- `--outputfile` now maps to `icloud devices export DEVICE --output PATH` -- device action flags now map to explicit commands such as - `icloud devices sound DEVICE`, `icloud devices message DEVICE ...`, and - `icloud devices lost-mode DEVICE ...` - -**Note**: Authentication will expire after an interval set by Apple, at -which point you will have to re-authenticate. This interval is currently -two months. +**Note**: Authentication expires on an interval set by Apple, at which +point you will have to authenticate again. **Note**: Apple will require you to accept new terms and conditions to access the iCloud web service. This will result in login failures until the terms are accepted. This can be automatically accepted by PyiCloud -using the `--accept-terms` command-line option. Alternatively you can -visit the iCloud web site to view and accept the terms. +using `icloud auth login --accept-terms`. Alternatively you can visit +the iCloud web site to view and accept the terms. ### Two-step and two-factor authentication (2SA/2FA) diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index 727580a1..71923b62 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -11,9 +11,7 @@ from pyicloud.cli.commands.devices import app as devices_app from pyicloud.cli.commands.drive import app as drive_app from pyicloud.cli.commands.hidemyemail import app as hidemyemail_app -from pyicloud.cli.commands.notes import app as notes_app from pyicloud.cli.commands.photos import app as photos_app -from pyicloud.cli.commands.reminders import app as reminders_app from pyicloud.cli.context import CLIAbort app = typer.Typer( @@ -41,10 +39,6 @@ def _group_root(ctx: typer.Context) -> None: app.add_typer( hidemyemail_app, name="hidemyemail", invoke_without_command=True, callback=_group_root ) -app.add_typer( - reminders_app, name="reminders", invoke_without_command=True, callback=_group_root -) -app.add_typer(notes_app, name="notes", invoke_without_command=True, callback=_group_root) def main() -> int: diff --git a/pyicloud/cli/commands/notes.py b/pyicloud/cli/commands/notes.py deleted file mode 100644 index fcceacc0..00000000 --- a/pyicloud/cli/commands/notes.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Notes commands.""" - -from __future__ import annotations - -from itertools import islice -from pathlib import Path -from typing import Optional - -import typer - -from pyicloud.cli.context import get_state, service_call -from pyicloud.cli.normalize import select_recent_notes -from pyicloud.cli.options import with_service_command_options -from pyicloud.cli.output import console_table - -app = typer.Typer(help="Inspect, render, and export Notes.") - - -@app.command("recent") -@with_service_command_options -def notes_recent( - ctx: typer.Context, - limit: int = typer.Option(10, "--limit", min=1, help="Maximum notes to show."), - include_deleted: bool = typer.Option( - False, - "--include-deleted", - help="Include notes from Recently Deleted.", - ), -) -> None: - """List recent notes.""" - - state = get_state(ctx) - api = state.get_api() - rows = service_call( - "Notes", - lambda: select_recent_notes(api, limit=limit, include_deleted=include_deleted), - ) - if state.json_output: - state.write_json(rows) - return - state.console.print( - console_table( - "Recent Notes", - ["ID", "Title", "Folder", "Modified"], - [(row.id, row.title, row.folder_name, row.modified_at) for row in rows], - ) - ) - - -@app.command("folders") -@with_service_command_options -def notes_folders(ctx: typer.Context) -> None: - """List note folders.""" - - state = get_state(ctx) - api = state.get_api() - rows = list(service_call("Notes", lambda: api.notes.folders())) - if state.json_output: - state.write_json(rows) - return - state.console.print( - console_table( - "Note Folders", - ["ID", "Name", "Parent", "Has Subfolders"], - [(row.id, row.name, row.parent_id, row.has_subfolders) for row in rows], - ) - ) - - -@app.command("list") -@with_service_command_options -def notes_list( - ctx: typer.Context, - folder_id: Optional[str] = typer.Option(None, "--folder-id", help="Folder id."), - all_notes: bool = typer.Option(False, "--all", help="Iterate all notes."), - limit: int = typer.Option(50, "--limit", min=1, help="Maximum notes to show."), - since: Optional[str] = typer.Option( - None, "--since", help="Incremental sync cursor for iter_all()." - ), -) -> None: - """List notes.""" - - state = get_state(ctx) - api = state.get_api() - if folder_id: - rows = list( - service_call("Notes", lambda: api.notes.in_folder(folder_id, limit=limit)) - ) - elif all_notes: - rows = list( - islice( - service_call("Notes", lambda: api.notes.iter_all(since=since)), limit - ) - ) - else: - rows = list(service_call("Notes", lambda: api.notes.recents(limit=limit))) - if state.json_output: - state.write_json(rows) - return - state.console.print( - console_table( - "Notes", - ["ID", "Title", "Folder", "Modified"], - [(row.id, row.title, row.folder_name, row.modified_at) for row in rows], - ) - ) - - -@app.command("get") -@with_service_command_options -def notes_get( - ctx: typer.Context, - note_id: str = typer.Argument(...), - with_attachments: bool = typer.Option(False, "--with-attachments"), -) -> None: - """Get one note.""" - - state = get_state(ctx) - api = state.get_api() - note = service_call( - "Notes", - lambda: api.notes.get(note_id, with_attachments=with_attachments), - ) - if state.json_output: - state.write_json(note) - return - state.console.print(f"{note.title} [{note.id}]") - if note.text: - state.console.print(note.text) - if with_attachments and note.attachments: - state.console.print( - console_table( - "Attachments", - ["ID", "Filename", "UTI", "Size"], - [(att.id, att.filename, att.uti, att.size) for att in note.attachments], - ) - ) - - -@app.command("render") -@with_service_command_options -def notes_render( - ctx: typer.Context, - note_id: str = typer.Argument(...), - preview_appearance: str = typer.Option("light", "--preview-appearance"), - pdf_height: int = typer.Option(600, "--pdf-height"), -) -> None: - """Render a note to HTML.""" - - state = get_state(ctx) - api = state.get_api() - html = service_call( - "Notes", - lambda: api.notes.render_note( - note_id, - preview_appearance=preview_appearance, - pdf_object_height=pdf_height, - ), - ) - if state.json_output: - state.write_json({"note_id": note_id, "html": html}) - return - state.console.print(html, soft_wrap=True) - - -@app.command("export") -@with_service_command_options -def notes_export( - ctx: typer.Context, - note_id: str = typer.Argument(...), - output_dir: Path = typer.Argument(...), - export_mode: str = typer.Option("archival", "--export-mode"), - assets_dir: Optional[Path] = typer.Option(None, "--assets-dir"), - full_page: bool = typer.Option(True, "--full-page/--fragment"), - preview_appearance: str = typer.Option("light", "--preview-appearance"), - pdf_height: int = typer.Option(600, "--pdf-height"), -) -> None: - """Export a note to disk.""" - - state = get_state(ctx) - api = state.get_api() - path = service_call( - "Notes", - lambda: api.notes.export_note( - note_id, - str(output_dir), - export_mode=export_mode, - assets_dir=str(assets_dir) if assets_dir else None, - full_page=full_page, - preview_appearance=preview_appearance, - pdf_object_height=pdf_height, - ), - ) - if state.json_output: - state.write_json({"note_id": note_id, "path": path}) - return - state.console.print(path) - - -@app.command("changes") -@with_service_command_options -def notes_changes( - ctx: typer.Context, - since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), - limit: int = typer.Option(50, "--limit", min=1, help="Maximum changes to show."), -) -> None: - """List note changes since a cursor.""" - - state = get_state(ctx) - api = state.get_api() - rows = list( - islice( - service_call("Notes", lambda: api.notes.iter_changes(since=since)), limit - ) - ) - if state.json_output: - state.write_json(rows) - return - state.console.print( - console_table( - "Note Changes", - ["Type", "Note ID", "Folder", "Modified"], - [ - ( - row.type, - row.note.id if row.note else row.note_id, - row.note.folder_name if row.note else None, - row.note.modified_at if row.note else None, - ) - for row in rows - ], - ) - ) diff --git a/pyicloud/cli/commands/reminders.py b/pyicloud/cli/commands/reminders.py deleted file mode 100644 index e2e78c89..00000000 --- a/pyicloud/cli/commands/reminders.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Reminders commands.""" - -from __future__ import annotations - -from itertools import islice -from typing import Optional - -import typer - -from pyicloud.cli.context import get_state, parse_datetime, service_call -from pyicloud.cli.options import with_service_command_options -from pyicloud.cli.output import console_table, format_color_value - -app = typer.Typer(help="Inspect and mutate Reminders.") - - -@app.command("lists") -@with_service_command_options -def reminders_lists(ctx: typer.Context) -> None: - """List reminder lists.""" - - state = get_state(ctx) - api = state.get_api() - rows = list(service_call("Reminders", lambda: api.reminders.lists())) - if state.json_output: - state.write_json(rows) - return - state.console.print( - console_table( - "Reminder Lists", - ["ID", "Title", "Color", "Count"], - [ - (row.id, row.title, format_color_value(row.color), row.count) - for row in rows - ], - ) - ) - - -@app.command("list") -@with_service_command_options -def reminders_list( - ctx: typer.Context, - list_id: Optional[str] = typer.Option(None, "--list-id", help="List id."), - include_completed: bool = typer.Option( - False, "--include-completed", help="Include completed reminders." - ), - limit: int = typer.Option(50, "--limit", min=1, help="Maximum reminders to show."), -) -> None: - """List reminders.""" - - state = get_state(ctx) - api = state.get_api() - reminders = list( - islice( - service_call( - "Reminders", - lambda: api.reminders.reminders( - list_id=list_id, - include_completed=include_completed, - ), - ), - limit, - ) - ) - if state.json_output: - state.write_json(reminders) - return - state.console.print( - console_table( - "Reminders", - ["ID", "Title", "Completed", "Due", "Priority"], - [ - ( - reminder.id, - reminder.title, - reminder.completed, - reminder.due_date, - reminder.priority, - ) - for reminder in reminders - ], - ) - ) - - -@app.command("get") -@with_service_command_options -def reminders_get(ctx: typer.Context, reminder_id: str = typer.Argument(...)) -> None: - """Get one reminder.""" - - state = get_state(ctx) - api = state.get_api() - reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) - if state.json_output: - state.write_json(reminder) - return - state.console.print(f"{reminder.title} [{reminder.id}]") - if reminder.desc: - state.console.print(reminder.desc) - if reminder.due_date: - state.console.print(f"Due: {reminder.due_date}") - - -@app.command("create") -@with_service_command_options -def reminders_create( - ctx: typer.Context, - list_id: str = typer.Option(..., "--list-id", help="Target list id."), - title: str = typer.Option(..., "--title", help="Reminder title."), - desc: str = typer.Option("", "--desc", help="Reminder description."), - due_date: Optional[str] = typer.Option(None, "--due-date", help="Due datetime."), - priority: int = typer.Option(0, "--priority", help="Apple priority number."), - flagged: bool = typer.Option(False, "--flagged", help="Flag the reminder."), - all_day: bool = typer.Option(False, "--all-day", help="Mark as all-day."), -) -> None: - """Create a reminder.""" - - state = get_state(ctx) - api = state.get_api() - reminder = service_call( - "Reminders", - lambda: api.reminders.create( - list_id=list_id, - title=title, - desc=desc, - due_date=parse_datetime(due_date), - priority=priority, - flagged=flagged, - all_day=all_day, - ), - ) - if state.json_output: - state.write_json(reminder) - return - state.console.print(reminder.id) - - -@app.command("set-status") -@with_service_command_options -def reminders_set_status( - ctx: typer.Context, - reminder_id: str = typer.Argument(...), - completed: bool = typer.Option(True, "--completed/--not-completed"), -) -> None: - """Mark a reminder completed or incomplete.""" - - state = get_state(ctx) - api = state.get_api() - reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) - reminder.completed = completed - service_call("Reminders", lambda: api.reminders.update(reminder)) - if state.json_output: - state.write_json(reminder) - return - state.console.print(f"Updated {reminder.id}: completed={completed}") - - -@app.command("delete") -@with_service_command_options -def reminders_delete( - ctx: typer.Context, reminder_id: str = typer.Argument(...) -) -> None: - """Delete a reminder.""" - - state = get_state(ctx) - api = state.get_api() - reminder = service_call("Reminders", lambda: api.reminders.get(reminder_id)) - service_call("Reminders", lambda: api.reminders.delete(reminder)) - if state.json_output: - state.write_json({"reminder_id": reminder.id, "deleted": True}) - return - state.console.print(f"Deleted {reminder.id}") - - -@app.command("changes") -@with_service_command_options -def reminders_changes( - ctx: typer.Context, - since: Optional[str] = typer.Option(None, "--since", help="Sync cursor."), - limit: int = typer.Option(50, "--limit", min=1, help="Maximum changes to show."), -) -> None: - """List reminder changes since a cursor.""" - - state = get_state(ctx) - api = state.get_api() - events = list( - islice( - service_call("Reminders", lambda: api.reminders.iter_changes(since=since)), - limit, - ) - ) - if state.json_output: - state.write_json(events) - return - state.console.print( - console_table( - "Reminder Changes", - ["Type", "Reminder ID", "Has Reminder"], - [ - (event.type, event.reminder_id, event.reminder is not None) - for event in events - ], - ) - ) diff --git a/pyicloud/cli/normalize.py b/pyicloud/cli/normalize.py index acdd1c9d..acc65557 100644 --- a/pyicloud/cli/normalize.py +++ b/pyicloud/cli/normalize.py @@ -174,23 +174,3 @@ def normalize_alias(alias: dict[str, Any]) -> dict[str, Any]: "label": alias.get("label"), "anonymous_id": alias.get("anonymousId"), } - - -def select_recent_notes(api: Any, *, limit: int, include_deleted: bool) -> list[Any]: - """Return recent notes, excluding deleted notes by default.""" - - if include_deleted: - return list(api.notes.recents(limit=limit)) - - probe_limit = limit - max_probe = min(max(limit, 10) * 8, 500) - while True: - rows = list(api.notes.recents(limit=probe_limit)) - filtered = [row for row in rows if not getattr(row, "is_deleted", False)] - if ( - len(filtered) >= limit - or len(rows) < probe_limit - or probe_limit >= max_probe - ): - return filtered[:limit] - probe_limit = min(probe_limit * 2, max_probe) diff --git a/pyicloud/cli/output.py b/pyicloud/cli/output.py index 77256147..9d816da4 100644 --- a/pyicloud/cli/output.py +++ b/pyicloud/cli/output.py @@ -119,37 +119,3 @@ def print_json_text(console: Console, payload: Any) -> None: """Pretty-print a JSON object in text mode.""" console.print_json(json=to_json_string(payload, indent=2)) - - -def format_color_value(value: Any) -> str: - """Return a compact human-friendly representation of reminder colors.""" - - if not value: - return "" - - payload = value - if isinstance(value, str): - stripped = value.strip() - if not stripped: - return "" - if not stripped.startswith("{"): - return stripped - try: - payload = json.loads(stripped) - except json.JSONDecodeError: - return stripped - - if isinstance(payload, dict): - hex_value = payload.get("daHexString") - symbolic = payload.get("ckSymbolicColorName") or payload.get( - "daSymbolicColorName" - ) - if hex_value and symbolic and symbolic != "custom": - return f"{symbolic} ({hex_value})" - if hex_value: - return str(hex_value) - if symbolic: - return str(symbolic) - return to_json_string(payload) - - return str(payload) diff --git a/pyproject.toml b/pyproject.toml index 6033255d..0f123194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ repository = "https://github.com/timlaing/pyicloud" icloud = "pyicloud.cmdline:main" [tool.setuptools] -packages = ["pyicloud", "pyicloud.services"] +packages = ["pyicloud", "pyicloud.cli", "pyicloud.cli.commands", "pyicloud.services"] [tool.setuptools.dynamic] readme = {file = "README.md", content-type = "text/markdown"} diff --git a/requirements.txt b/requirements.txt index f2e8da2b..bca6c954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ fido2>=2.0.0 keyring>=25.6.0 keyrings.alt>=5.0.2 requests>=2.31.0 +rich>=13.0.0 srp>=1.0.21 typer>=0.16.1 tzlocal==5.3.1 diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 0b3f5376..e4b43922 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -5,11 +5,10 @@ import importlib import json from contextlib import nullcontext -from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace -from typing import Any, Iterable, Optional +from typing import Any, Optional from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -28,7 +27,7 @@ class FakeDevice: def __init__(self) -> None: self.id = "device-1" - self.name = "Jacob's iPhone" + self.name = "Example iPhone" self.deviceDisplayName = "iPhone" self.deviceClass = "iPhone" self.deviceModel = "iPhone16,1" @@ -188,170 +187,6 @@ def delete(self, anonymous_id: str) -> dict[str, Any]: return {"anonymousId": anonymous_id, "deleted": True} -@dataclass -class FakeReminder: - """Reminder fixture.""" - - id: str - title: str - completed: bool = False - due_date: Optional[datetime] = None - priority: int = 0 - desc: str = "" - - -@dataclass -class FakeNoteSummary: - """Note summary fixture.""" - - id: str - title: str - folder_name: str - modified_at: datetime - is_deleted: bool = False - - -@dataclass -class FakeNote: - """Note fixture.""" - - id: str - title: str - text: str - attachments: Optional[list[Any]] = None - - -@dataclass -class FakeChange: - """Change fixture.""" - - type: str - reminder_id: Optional[str] = None - reminder: Optional[Any] = None - note_id: Optional[str] = None - note: Optional[Any] = None - - -class FakeReminders: - """Reminders service fixture.""" - - def __init__(self) -> None: - self._lists = [ - SimpleNamespace( - id="list-1", - title="Inbox", - color='{"daHexString":"#007AFF","ckSymbolicColorName":"blue"}', - count=2, - ) - ] - self._reminders = [ - FakeReminder(id="rem-1", title="Buy milk", priority=1), - FakeReminder(id="rem-2", title="Pay rent", completed=True), - ] - - def lists(self) -> Iterable[Any]: - return list(self._lists) - - def reminders( - self, list_id: Optional[str] = None, include_completed: bool = False - ) -> Iterable[FakeReminder]: - if include_completed: - return list(self._reminders) - return [reminder for reminder in self._reminders if not reminder.completed] - - def get(self, reminder_id: str) -> FakeReminder: - for reminder in self._reminders: - if reminder.id == reminder_id: - return reminder - raise KeyError(reminder_id) - - def create(self, **kwargs: Any) -> FakeReminder: - reminder = FakeReminder( - id="rem-created", - title=kwargs["title"], - due_date=kwargs.get("due_date"), - priority=kwargs.get("priority", 0), - desc=kwargs.get("desc", ""), - ) - self._reminders.append(reminder) - return reminder - - def update(self, reminder: FakeReminder) -> None: - return None - - def delete(self, reminder: FakeReminder) -> None: - reminder.completed = True - - def iter_changes(self, since: Optional[str] = None): - yield FakeChange( - type="updated", reminder_id="rem-1", reminder=self._reminders[0] - ) - - -class FakeNotes: - """Notes service fixture.""" - - def __init__(self) -> None: - self._recent = [ - FakeNoteSummary( - id="note-deleted", - title="Deleted Note", - folder_name="Recently Deleted", - modified_at=datetime(2026, 3, 2, tzinfo=timezone.utc), - is_deleted=True, - ), - FakeNoteSummary( - id="note-1", - title="Daily Plan", - folder_name="Notes", - modified_at=datetime(2026, 3, 1, tzinfo=timezone.utc), - ), - ] - self._folders = [ - SimpleNamespace( - id="folder-1", - name="Notes", - parent_id=None, - has_subfolders=False, - ) - ] - - def recents(self, *, limit: int = 50): - return self._recent[:limit] - - def folders(self): - return list(self._folders) - - def in_folder(self, folder_id: str, limit: int = 50): - return self._recent[:limit] - - def iter_all(self, since: Optional[str] = None): - return iter(self._recent) - - def get(self, note_id: str, *, with_attachments: bool = False): - attachments = ( - [ - SimpleNamespace( - id="att-1", filename="file.pdf", uti="com.adobe.pdf", size=12 - ) - ] - if with_attachments - else None - ) - return FakeNote( - id=note_id, title="Daily Plan", text="Ship CLI", attachments=attachments - ) - - def render_note(self, note_id: str, **kwargs: Any) -> str: - return f"

{note_id}

" - - def export_note(self, note_id: str, output_dir: str, **kwargs: Any) -> str: - return str(Path(output_dir) / f"{note_id}.html") - - def iter_changes(self, since: Optional[str] = None): - yield FakeChange(type="updated", note_id="note-1", note=self.get("note-1")) - - class FakeAPI: """Authenticated API fixture.""" @@ -395,7 +230,7 @@ def __init__( self.account = SimpleNamespace( devices=[ { - "name": "Jacob's iPhone", + "name": "Example iPhone", "modelDisplayName": "iPhone 16 Pro", "deviceClass": "iPhone", "id": "acc-device-1", @@ -476,8 +311,6 @@ def __init__( all=photo_album, ) self.hidemyemail = FakeHideMyEmail() - self.reminders = FakeReminders() - self.notes = FakeNotes() def _logout( self, @@ -660,8 +493,6 @@ def test_root_help() -> None: "drive", "photos", "hidemyemail", - "reminders", - "notes", ): assert command in result.stdout @@ -678,8 +509,6 @@ def test_group_help() -> None: "drive", "photos", "hidemyemail", - "reminders", - "notes", ): result = _runner().invoke(app, [command, "--help"]) assert result.exit_code == 0 @@ -697,8 +526,6 @@ def test_bare_group_invocation_shows_help() -> None: "drive", "photos", "hidemyemail", - "reminders", - "notes", ): result = _runner().invoke(app, [command]) assert result.exit_code == 0 @@ -1618,7 +1445,7 @@ def test_devices_list_and_show_commands() -> None: output_format="json", ) assert list_result.exit_code == 0 - assert "Jacob's iPhone" in list_result.stdout + assert "Example iPhone" in list_result.stdout assert show_result.exit_code == 0 assert "Battery Status" in show_result.stdout assert raw_result.exit_code == 0 @@ -1683,7 +1510,7 @@ def test_devices_mutations_and_export() -> None: assert export_result.exit_code == 0 assert json.loads(export_result.stdout)["path"] == str(export_path) assert ( - json.loads(export_path.read_text(encoding="utf-8"))["name"] == "Jacob's iPhone" + json.loads(export_path.read_text(encoding="utf-8"))["name"] == "Example iPhone" ) @@ -1744,66 +1571,6 @@ def test_hidemyemail_commands() -> None: assert "generated@privaterelay.appleid.com" in generate_result.stdout -def test_reminders_commands() -> None: - """Reminders commands should expose list and create flows.""" - - fake_api = FakeAPI() - lists_result = _invoke(fake_api, "reminders", "lists") - list_result = _invoke(fake_api, "reminders", "list") - create_result = _invoke( - fake_api, - "reminders", - "create", - "--list-id", - "list-1", - "--title", - "New task", - "--priority", - "5", - output_format="json", - ) - assert lists_result.exit_code == 0 - assert "blue (#007AFF)" in lists_result.stdout - assert list_result.exit_code == 0 - assert "Buy milk" in list_result.stdout - assert create_result.exit_code == 0 - assert json.loads(create_result.stdout)["id"] == "rem-created" - - -def test_notes_commands() -> None: - """Notes commands should expose recent, get, render, and export flows.""" - - fake_api = FakeAPI() - output_dir = Path("/tmp/python-test-results/test_cmdline/notes") - output_dir.mkdir(parents=True, exist_ok=True) - recent_result = _invoke(fake_api, "notes", "recent", "--limit", "1") - include_deleted_result = _invoke( - fake_api, "notes", "recent", "--limit", "1", "--include-deleted" - ) - render_result = _invoke( - fake_api, - "notes", - "render", - "note-1", - output_format="json", - ) - export_result = _invoke( - fake_api, - "notes", - "export", - "note-1", - str(output_dir), - output_format="json", - ) - assert recent_result.exit_code == 0 - assert "Daily Plan" in recent_result.stdout - assert "Deleted Note" not in recent_result.stdout - assert include_deleted_result.exit_code == 0 - assert "Deleted Note" in include_deleted_result.stdout - assert render_result.exit_code == 0 - assert json.loads(render_result.stdout)["html"] == "

note-1

" - assert export_result.exit_code == 0 - assert json.loads(export_result.stdout)["path"] == str(output_dir / "note-1.html") def test_main_returns_clean_error_for_user_abort(capsys) -> None: From 05155cac8aec34146a9e61bf9b7aafe151dbfdec Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 11:55:52 +0100 Subject: [PATCH 11/18] Fix README example correctness --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29eccbef..67f41f07 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,10 @@ authentication (2SA)](https://support.apple.com/en-us/HT204152) for the account you will have to do some extra work: ```python +import sys + +import click + if api.requires_2fa: security_key_names = api.security_key_names @@ -233,7 +237,6 @@ if api.requires_2fa: ) elif api.requires_2sa: - import click print("Two-step authentication required. Your trusted devices are:") devices = api.trusted_devices @@ -568,8 +571,8 @@ You can access your iCloud contacts/address book through the `contacts` property: ```pycon ->>> for c in api.contacts.all(): ->>> print(c.get('firstName'), c.get('phones')) +>>> for c in api.contacts.all: +... print(c.get('firstName'), c.get('phones')) John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}] ``` From 8ac8f8c94edb01a5094c746472b190182b75aefc Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 15:21:14 +0100 Subject: [PATCH 12/18] Fix CLI session reauth and test stability --- pyicloud/cli/commands/account.py | 22 ++- pyicloud/cli/commands/devices.py | 6 +- pyicloud/cli/context.py | 121 +++++++++++-- tests/conftest.py | 34 ++-- tests/test_cmdline.py | 300 ++++++++++++++++++++++++++++++- 5 files changed, 448 insertions(+), 35 deletions(-) diff --git a/pyicloud/cli/commands/account.py b/pyicloud/cli/commands/account.py index bf32c3a1..ebd1d195 100644 --- a/pyicloud/cli/commands/account.py +++ b/pyicloud/cli/commands/account.py @@ -24,7 +24,9 @@ def account_summary(ctx: typer.Context) -> None: state = get_state(ctx) api = state.get_api() - account = service_call("Account", lambda: api.account) + account = service_call( + "Account", lambda: api.account, account_name=api.account_name + ) payload = normalize_account_summary(api, account) if state.json_output: state.write_json(payload) @@ -47,7 +49,11 @@ def account_devices(ctx: typer.Context) -> None: api = state.get_api() payload = [ normalize_account_device(device) - for device in service_call("Account", lambda: api.account.devices) + for device in service_call( + "Account", + lambda: api.account.devices, + account_name=api.account_name, + ) ] if state.json_output: state.write_json(payload) @@ -78,7 +84,11 @@ def account_family(ctx: typer.Context) -> None: api = state.get_api() payload = [ normalize_family_member(member) - for member in service_call("Account", lambda: api.account.family) + for member in service_call( + "Account", + lambda: api.account.family, + account_name=api.account_name, + ) ] if state.json_output: state.write_json(payload) @@ -107,7 +117,11 @@ def account_storage(ctx: typer.Context) -> None: state = get_state(ctx) api = state.get_api() - payload = normalize_storage(service_call("Account", lambda: api.account.storage)) + payload = normalize_storage( + service_call( + "Account", lambda: api.account.storage, account_name=api.account_name + ) + ) if state.json_output: state.write_json(payload) return diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 201d9b0f..9377fa81 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -33,7 +33,11 @@ def devices_list( api = state.get_api() payload = [ normalize_device_summary(device, locate=locate) - for device in service_call("Find My", lambda: api.devices) + for device in service_call( + "Find My", + lambda: api.devices, + account_name=api.account_name, + ) ] if state.json_output: state.write_json(payload) diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index 18a8a723..3cfcad6a 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -16,10 +16,19 @@ from pyicloud import PyiCloudService, utils from pyicloud.base import resolve_cookie_directory -from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable +from pyicloud.exceptions import ( + PyiCloudAuthRequiredException, + PyiCloudFailedLoginException, + PyiCloudServiceUnavailable, +) from pyicloud.ssl_context import configurable_ssl_verification -from .account_index import AccountIndexEntry, load_accounts, prune_accounts, remember_account +from .account_index import ( + AccountIndexEntry, + load_accounts, + prune_accounts, + remember_account, +) from .output import OutputFormat, write_json COMMAND_OPTIONS_META_KEY = "command_options" @@ -104,6 +113,7 @@ def __init__( self._api: Optional[PyiCloudService] = None self._probe_api: Optional[PyiCloudService] = None self._resolved_username: Optional[str] = self.username or None + self._logging_configured = False @classmethod def from_options(cls, options: CLICommandOptions) -> "CLIState": @@ -265,10 +275,31 @@ def multiple_logged_in_accounts_message(usernames: list[str]) -> str: f"{options}" ) - def _password_for_login(self, username: str) -> Optional[str]: + def _password_for_login(self, username: str) -> tuple[Optional[str], Optional[str]]: + if self.password: + return self.password, "explicit" + + keyring_password = utils.get_password_from_keyring(username) + if keyring_password: + return keyring_password, "keyring" + + if not self.interactive: + return None, None + + return utils.get_password(username, interactive=True), "prompt" + + def _configure_logging(self) -> None: + if self._logging_configured: + return + logging.basicConfig(level=self.log_level.logging_level()) + self._logging_configured = True + + def _stored_password_for_session(self, username: str) -> Optional[str]: + """Return a non-interactive password for service-level reauthentication.""" + if self.password: return self.password - return utils.get_password(username, interactive=self.interactive) + return utils.get_password_from_keyring(username) def _prompt_index(self, prompt: str, count: int) -> int: if count <= 1 or not self.interactive: @@ -338,11 +369,11 @@ def get_login_api(self) -> PyiCloudService: return self._api username = self._resolve_username() - password = self._password_for_login(username) + password, password_source = self._password_for_login(username) if not password: raise CLIAbort("No password supplied and no stored password was found.") - logging.basicConfig(level=self.log_level.logging_level()) + self._configure_logging() try: api = PyiCloudService( @@ -354,7 +385,9 @@ def get_login_api(self) -> PyiCloudService: with_family=self.with_family, ) except PyiCloudFailedLoginException as err: - if utils.password_exists_in_keyring(username): + if password_source == "keyring" and utils.password_exists_in_keyring( + username + ): utils.delete_password_in_keyring(username) self.prune_local_accounts() raise CLIAbort(f"Bad username or password for {username}") from err @@ -383,10 +416,15 @@ def get_api(self) -> PyiCloudService: if self.has_explicit_username: username = self._resolve_username() - api = self.build_probe_api(username) - status = api.get_auth_status() + probe_api = self.build_probe_api(username) + status = probe_api.get_auth_status() if not status["authenticated"]: raise CLIAbort(self.not_logged_in_for_account_message(username)) + api = self.build_session_api(username) + if not self._hydrate_api_from_probe(api, probe_api): + status = api.get_auth_status() + if not status["authenticated"]: + raise CLIAbort(self.not_logged_in_for_account_message(username)) self._api = api self.remember_account(api) return api @@ -401,7 +439,14 @@ def get_api(self) -> PyiCloudService: ) ) - api, _status = active_probes[0] + probe_api, _status = active_probes[0] + api = self.build_session_api(probe_api.account_name) + if not self._hydrate_api_from_probe(api, probe_api): + status = api.get_auth_status() + if not status["authenticated"]: + raise CLIAbort( + self.not_logged_in_for_account_message(probe_api.account_name) + ) self._api = api self.remember_account(api) return api @@ -409,7 +454,7 @@ def get_api(self) -> PyiCloudService: def build_probe_api(self, username: str) -> PyiCloudService: """Build a non-authenticating PyiCloudService for session probes.""" - logging.basicConfig(level=self.log_level.logging_level()) + self._configure_logging() return PyiCloudService( apple_id=username, password=self.password, @@ -420,6 +465,45 @@ def build_probe_api(self, username: str) -> PyiCloudService: authenticate=False, ) + def build_session_api(self, username: str) -> PyiCloudService: + """Build a session-backed API that can satisfy service reauthentication.""" + + self._configure_logging() + return PyiCloudService( + apple_id=username, + password=self._stored_password_for_session(username), + china_mainland=self.resolved_china_mainland(username), + cookie_directory=self.session_dir, + accept_terms=self.accept_terms, + with_family=self.with_family, + authenticate=False, + ) + + @staticmethod + def _hydrate_api_from_probe( + api: PyiCloudService, probe_api: Optional[PyiCloudService] + ) -> bool: + """Populate auth-derived state on a session-backed API from a probe.""" + + if probe_api is None: + return False + + probe_data = getattr(probe_api, "data", None) + if not isinstance(probe_data, dict) or not probe_data: + return False + + api.data = dict(probe_data) + + params = getattr(api, "params", None) + ds_info = probe_data.get("dsInfo") + if isinstance(params, dict) and isinstance(ds_info, dict) and "dsid" in ds_info: + params.update({"dsid": ds_info["dsid"]}) + + if "webservices" in probe_data: + setattr(api, "_webservices", probe_data["webservices"]) + + return True + def get_probe_api(self) -> PyiCloudService: """Return a cached non-authenticating PyiCloudService for session probes.""" @@ -478,13 +562,22 @@ def get_state(ctx: typer.Context) -> CLIState: return resolved -def service_call(label: str, fn): +def service_call(label: str, fn, *, account_name: Optional[str] = None): """Wrap a service call with user-facing service-unavailable handling.""" try: return fn() except PyiCloudServiceUnavailable as err: raise CLIAbort(f"{label} service unavailable: {err}") from err + except (PyiCloudAuthRequiredException, PyiCloudFailedLoginException) as err: + if account_name: + raise CLIAbort( + f"{label} requires re-authentication for {account_name}. " + f"Run: icloud auth login --username {account_name}" + ) from err + raise CLIAbort( + f"{label} requires re-authentication. Run: icloud auth login." + ) from err def parse_datetime(value: Optional[str]) -> Optional[datetime]: @@ -508,7 +601,9 @@ def resolve_device(api: PyiCloudService, query: str): """Return a device matched by id or common display names.""" lowered = query.strip().lower() - for device in api.devices: + for device in service_call( + "Find My", lambda: api.devices, account_name=api.account_name + ): candidates = [ getattr(device, "id", ""), getattr(device, "name", ""), diff --git a/tests/conftest.py b/tests/conftest.py index 314bb986..1f499327 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,15 @@ class FileSystemAccessError(Exception): """Raised when a test tries to access the file system.""" +def normalize_path(path: Any) -> str: + """Normalize PathLike objects before file-system guard checks.""" + + try: + return str(os.fspath(path)) + except TypeError: + return str(path) + + @pytest.fixture(autouse=True, scope="function") def mock_file_open_write_fixture(): """Mock the open function to prevent file system access.""" @@ -73,9 +82,10 @@ def mock_mkdir(): mkdir = os.mkdir def my_mkdir(path, *args, **kwargs): - if "python-test-results" not in path: + normalized = normalize_path(path) + if "python-test-results" not in normalized: raise FileSystemAccessError( - f"You should not be creating directories in tests. {path}" + f"You should not be creating directories in tests. {normalized}" ) return mkdir(path, *args, **kwargs) @@ -89,9 +99,10 @@ def mock_makedirs(): mkdirs = os.makedirs def my_makedirs(path, *args, **kwargs): - if "python-test-results" not in path: + normalized = normalize_path(path) + if "python-test-results" not in normalized: raise FileSystemAccessError( - f"You should not be creating directories in tests. {path}" + f"You should not be creating directories in tests. {normalized}" ) return mkdirs(path, *args, **kwargs) @@ -105,9 +116,10 @@ def mock_chmod(): chmod = os.chmod def my_chmod(path, *args, **kwargs): - if "python-test-results" not in path: + normalized = normalize_path(path) + if "python-test-results" not in normalized: raise FileSystemAccessError( - f"You should not be changing file permissions in tests. {path}" + f"You should not be changing file permissions in tests. {normalized}" ) return chmod(path, *args, **kwargs) @@ -121,9 +133,10 @@ def mock_open_fixture(): builtins_open = open def my_open(path, *args, **kwargs): - if "python-test-results" not in path: + normalized = normalize_path(path) + if "python-test-results" not in normalized: raise FileSystemAccessError( - f"You should not be opening files in tests. {path}" + f"You should not be opening files in tests. {normalized}" ) return builtins_open(path, *args, **kwargs) @@ -137,9 +150,10 @@ def mock_os_open_fixture(): builtins_open = os.open def my_open(path, *args, **kwargs): - if "python-test-results" not in path: + normalized = normalize_path(path) + if "python-test-results" not in normalized: raise FileSystemAccessError( - f"You should not be opening files in tests. {path}" + f"You should not be opening files in tests. {normalized}" ) return builtins_open(path, *args, **kwargs) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index e4b43922..a4ddc756 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -17,6 +17,7 @@ account_index_module = importlib.import_module("pyicloud.cli.account_index") cli_module = importlib.import_module("pyicloud.cli.app") context_module = importlib.import_module("pyicloud.cli.context") +output_module = importlib.import_module("pyicloud.cli.output") app = cli_module.app TEST_ROOT = Path("/tmp/python-test-results/test_cmdline") @@ -225,6 +226,9 @@ def __init__( "requires_2sa": False, } ) + self.data: dict[str, Any] = {} + self.params: dict[str, Any] = {} + self._webservices: Any = None self.logout = MagicMock(side_effect=self._logout) self.devices = [FakeDevice()] self.account = SimpleNamespace( @@ -446,6 +450,13 @@ def _invoke( "password_exists_in_keyring", side_effect=lambda candidate: candidate in (keyring_passwords or set()), ), + patch.object( + context_module.utils, + "get_password_from_keyring", + side_effect=lambda candidate: ( + "stored-secret" if candidate in (keyring_passwords or set()) else None + ), + ), ): return runner.invoke(app, cli_args) @@ -468,6 +479,13 @@ def _invoke_with_cli_args( "password_exists_in_keyring", side_effect=lambda candidate: candidate in (keyring_passwords or set()), ), + patch.object( + context_module.utils, + "get_password_from_keyring", + side_effect=lambda candidate: ( + "stored-secret" if candidate in (keyring_passwords or set()) else None + ), + ), ): return runner.invoke(app, cli_args) @@ -645,6 +663,9 @@ def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: patch.object( context_module.utils, "password_exists_in_keyring", return_value=False ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): result = _runner().invoke( app, @@ -685,6 +706,9 @@ def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: patch.object( context_module.utils, "password_exists_in_keyring", return_value=False ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): result = _runner().invoke( app, @@ -711,7 +735,9 @@ def test_china_mainland_is_login_only() -> None: service_result = _runner().invoke(app, ["account", "summary", "--china-mainland"]) assert status_result.exit_code != 0 - assert "No such option: --china-mainland" in (status_result.stdout + status_result.stderr) + assert "No such option: --china-mainland" in ( + status_result.stdout + status_result.stderr + ) assert service_result.exit_code != 0 assert "No such option: --china-mainland" in ( service_result.stdout + service_result.stderr @@ -760,9 +786,12 @@ def fake_service(*, apple_id: str, china_mainland: Any, **_kwargs: Any) -> FakeA ) assert login_result.exit_code == 0 - assert account_index_module.load_accounts(session_dir)["cn@example.com"][ - "china_mainland" - ] is True + assert ( + account_index_module.load_accounts(session_dir)["cn@example.com"][ + "china_mainland" + ] + is True + ) def test_persisted_china_mainland_metadata_is_used_for_service_commands() -> None: @@ -793,6 +822,9 @@ def fake_service(*, apple_id: str, china_mainland: Any, **_kwargs: Any) -> FakeA patch.object( context_module.utils, "password_exists_in_keyring", return_value=False ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): result = _runner().invoke( app, @@ -1099,6 +1131,136 @@ def test_single_known_account_supports_implicit_local_context() -> None: ] == ["solo@example.com"] +def test_get_api_uses_keyring_password_for_session_backed_service_commands() -> None: + """Service commands should preload the stored password for service reauth.""" + + session_dir = _unique_session_dir("service-reauth-password") + _remember_local_account( + session_dir, + "solo@example.com", + has_session_file=True, + keyring_passwords={"solo@example.com"}, + ) + + probe_api = FakeAPI(username="solo@example.com", session_dir=session_dir) + service_api = FakeAPI(username="solo@example.com", session_dir=session_dir) + constructor_calls: list[dict[str, Any]] = [] + + def build_api(**kwargs: Any) -> FakeAPI: + constructor_calls.append(kwargs) + return probe_api if len(constructor_calls) == 1 else service_api + + state = context_module.CLIState( + username=None, + password=None, + china_mainland=None, + interactive=False, + accept_terms=False, + with_family=False, + session_dir=str(session_dir), + http_proxy=None, + https_proxy=None, + no_verify_ssl=False, + log_level=context_module.LogLevel.WARNING, + output_format=output_module.OutputFormat.TEXT, + ) + + with ( + patch.object(context_module, "PyiCloudService", side_effect=build_api), + patch.object( + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: candidate == "solo@example.com", + ), + patch.object( + context_module.utils, + "get_password_from_keyring", + return_value="stored-secret", + ), + ): + api = state.get_api() + + assert api is service_api + assert len(constructor_calls) == 2 + assert constructor_calls[0]["apple_id"] == "solo@example.com" + assert constructor_calls[0]["password"] is None + assert constructor_calls[0]["authenticate"] is False + assert constructor_calls[1]["apple_id"] == "solo@example.com" + assert constructor_calls[1]["password"] == "stored-secret" + assert constructor_calls[1]["authenticate"] is False + probe_api.get_auth_status.assert_called_once_with() + service_api.get_auth_status.assert_called_once_with() + + +def test_get_api_hydrates_session_backed_service_commands_from_probe_state() -> None: + """Service commands should reuse validated probe state for webservice access.""" + + session_dir = _unique_session_dir("service-reauth-hydration") + _remember_local_account( + session_dir, + "solo@example.com", + has_session_file=True, + keyring_passwords={"solo@example.com"}, + ) + + probe_api = FakeAPI(username="solo@example.com", session_dir=session_dir) + probe_api.data = { + "dsInfo": {"dsid": "1234567890", "hsaVersion": 2}, + "hsaTrustedBrowser": True, + "webservices": {"findme": {"url": "https://example.invalid/findme"}}, + } + service_api = FakeAPI(username="solo@example.com", session_dir=session_dir) + service_api.data = {} + service_api.params = {} + service_api._webservices = None + service_api.get_auth_status.side_effect = AssertionError( + "service API should be hydrated from the probe state" + ) + constructor_calls: list[dict[str, Any]] = [] + + def build_api(**kwargs: Any) -> FakeAPI: + constructor_calls.append(kwargs) + return probe_api if len(constructor_calls) == 1 else service_api + + state = context_module.CLIState( + username=None, + password=None, + china_mainland=None, + interactive=False, + accept_terms=False, + with_family=False, + session_dir=str(session_dir), + http_proxy=None, + https_proxy=None, + no_verify_ssl=False, + log_level=context_module.LogLevel.WARNING, + output_format=output_module.OutputFormat.TEXT, + ) + + with ( + patch.object(context_module, "PyiCloudService", side_effect=build_api), + patch.object( + context_module.utils, + "password_exists_in_keyring", + side_effect=lambda candidate: candidate == "solo@example.com", + ), + patch.object( + context_module.utils, + "get_password_from_keyring", + return_value="stored-secret", + ), + ): + api = state.get_api() + + assert api is service_api + assert len(constructor_calls) == 2 + assert service_api.data == probe_api.data + assert service_api.params["dsid"] == "1234567890" + assert service_api._webservices == probe_api.data["webservices"] + probe_api.get_auth_status.assert_called_once_with() + service_api.get_auth_status.assert_not_called() + + def test_multiple_local_accounts_require_explicit_username_for_auth_login() -> None: """Auth login should list local accounts when bootstrap discovery is ambiguous.""" @@ -1276,7 +1438,9 @@ def test_auth_login_non_interactive_requires_credentials() -> None: patch.object( context_module.utils, "password_exists_in_keyring", return_value=False ), - patch.object(context_module.utils, "get_password", return_value=None), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): result = _runner().invoke( app, @@ -1294,6 +1458,49 @@ def test_auth_login_non_interactive_requires_credentials() -> None: ) +def test_auth_login_explicit_password_does_not_delete_stored_keyring_secret() -> None: + """Explicit bad passwords should not delete a previously stored keyring password.""" + + with ( + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module, + "PyiCloudService", + side_effect=context_module.PyiCloudFailedLoginException("bad password"), + ), + patch.object(context_module, "confirm", return_value=False), + patch.object( + context_module.utils, "password_exists_in_keyring", return_value=True + ), + patch.object( + context_module.utils, + "get_password_from_keyring", + return_value="stored-secret", + ), + patch.object( + context_module.utils, "delete_password_in_keyring" + ) as delete_password, + ): + result = _runner().invoke( + app, + [ + "auth", + "login", + "--username", + "user@example.com", + "--password", + "wrong-secret", + "--non-interactive", + ], + ) + + assert result.exit_code != 0 + assert str(result.exception) == "Bad username or password for user@example.com" + delete_password.assert_not_called() + + def test_auth_logout_variants_and_remote_failure() -> None: """Auth logout should map semantic flags to Apple's payload and keep keyring intact.""" @@ -1452,6 +1659,87 @@ def test_devices_list_and_show_commands() -> None: assert json.loads(raw_result.stdout)["deviceDisplayName"] == "iPhone" +def test_devices_show_reports_reauthentication_requirement() -> None: + """Device resolution should collapse reauth failures into a CLIAbort.""" + + session_dir = _unique_session_dir("devices-show-reauth") + + class ReauthAPI: + def __init__(self) -> None: + self.account_name = "user@example.com" + self.is_china_mainland = False + self.session = SimpleNamespace( + session_path=str(session_dir / "userexamplecom.session"), + cookiejar_path=str(session_dir / "userexamplecom.cookiejar"), + ) + self.get_auth_status = MagicMock( + return_value={ + "authenticated": True, + "trusted_session": True, + "requires_2fa": False, + "requires_2sa": False, + } + ) + + @property + def devices(self): + raise context_module.PyiCloudFailedLoginException("No password set") + + result = _invoke( + ReauthAPI(), + "devices", + "show", + "Example iPhone", + session_dir=session_dir, + ) + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Find My requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + +def test_account_summary_reports_reauthentication_requirement() -> None: + """Account commands should collapse reauth failures into a CLIAbort.""" + + session_dir = _unique_session_dir("account-summary-reauth") + + class ReauthAPI: + def __init__(self) -> None: + self.account_name = "user@example.com" + self.is_china_mainland = False + self.session = SimpleNamespace( + session_path=str(session_dir / "userexamplecom.session"), + cookiejar_path=str(session_dir / "userexamplecom.cookiejar"), + ) + self.get_auth_status = MagicMock( + return_value={ + "authenticated": True, + "trusted_session": True, + "requires_2fa": False, + "requires_2sa": False, + } + ) + + @property + def account(self): + raise context_module.PyiCloudFailedLoginException("No password set") + + result = _invoke( + ReauthAPI(), + "account", + "summary", + session_dir=session_dir, + ) + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Account requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + def test_devices_mutations_and_export() -> None: """Device actions should map to the Find My device methods.""" @@ -1571,8 +1859,6 @@ def test_hidemyemail_commands() -> None: assert "generated@privaterelay.appleid.com" in generate_result.stdout - - def test_main_returns_clean_error_for_user_abort(capsys) -> None: """The entrypoint should not emit a traceback for expected CLI errors.""" From 824bf4f9ee4e126a1965708c8bf0ad3e6be2422c Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 16:15:36 +0100 Subject: [PATCH 13/18] Stabilize CLI surface and address review feedback --- pyicloud/cli/account_index.py | 126 ++++++--- pyicloud/cli/app.py | 29 +- pyicloud/cli/commands/account.py | 101 ++++++- pyicloud/cli/commands/auth.py | 103 ++++++- pyicloud/cli/commands/calendar.py | 53 +++- pyicloud/cli/commands/contacts.py | 53 +++- pyicloud/cli/commands/devices.py | 184 ++++++++++++- pyicloud/cli/commands/drive.py | 66 ++++- pyicloud/cli/commands/hidemyemail.py | 156 ++++++++++- pyicloud/cli/commands/photos.py | 122 ++++++++- pyicloud/cli/context.py | 49 +++- pyicloud/cli/options.py | 391 ++++++++++----------------- pyicloud/session.py | 9 +- tests/test_base.py | 18 ++ tests/test_cmdline.py | 219 ++++++++++++--- 15 files changed, 1266 insertions(+), 413 deletions(-) diff --git a/pyicloud/cli/account_index.py b/pyicloud/cli/account_index.py index 5385cdd2..54ca8b54 100644 --- a/pyicloud/cli/account_index.py +++ b/pyicloud/cli/account_index.py @@ -3,9 +3,12 @@ from __future__ import annotations import json +import os +import tempfile +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -from typing import Callable, TypedDict +from typing import Callable, Iterator, TypedDict ACCOUNT_INDEX_FILENAME = "accounts.json" @@ -26,10 +29,9 @@ def account_index_path(session_root: str | Path) -> Path: return Path(session_root) / ACCOUNT_INDEX_FILENAME -def load_accounts(session_root: str | Path) -> dict[str, AccountIndexEntry]: - """Load indexed accounts from disk.""" +def _load_accounts_from_path(index_path: Path) -> dict[str, AccountIndexEntry]: + """Load indexed accounts from a specific path.""" - index_path = account_index_path(session_root) try: raw = json.loads(index_path.read_text(encoding="utf-8")) except FileNotFoundError: @@ -62,12 +64,46 @@ def load_accounts(session_root: str | Path) -> dict[str, AccountIndexEntry]: return normalized +def load_accounts(session_root: str | Path) -> dict[str, AccountIndexEntry]: + """Load indexed accounts from disk.""" + + return _load_accounts_from_path(account_index_path(session_root)) + + +@contextmanager +def _locked_index(session_root: str | Path) -> Iterator[Path]: + """Serialize account index updates when the platform supports file locking.""" + + index_path = account_index_path(session_root) + index_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = index_path.with_suffix(f"{index_path.suffix}.lock") + with lock_path.open("a+", encoding="utf-8") as lock_file: + try: + import fcntl # pylint: disable=import-outside-toplevel + except ImportError: # pragma: no cover - Windows fallback + yield index_path + return + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield index_path + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def _save_accounts( session_root: str | Path, accounts: dict[str, AccountIndexEntry] ) -> None: """Persist indexed accounts to disk.""" - index_path = account_index_path(session_root) + _save_accounts_to_path(account_index_path(session_root), accounts) + + +def _save_accounts_to_path( + index_path: Path, accounts: dict[str, AccountIndexEntry] +) -> None: + """Persist indexed accounts to a specific path.""" + if not accounts: try: index_path.unlink() @@ -79,10 +115,28 @@ def _save_accounts( payload = { "accounts": {username: accounts[username] for username in sorted(accounts)} } - index_path.write_text( - json.dumps(payload, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=index_path.parent, + prefix=f".{index_path.name}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + temp_file.flush() + os.fsync(temp_file.fileno()) + temp_path = Path(temp_file.name) + os.replace(temp_path, index_path) + except Exception: + if temp_path is not None: + try: + temp_path.unlink() + except FileNotFoundError: + pass + raise def _is_discoverable( @@ -100,14 +154,15 @@ def prune_accounts( ) -> list[AccountIndexEntry]: """Drop stale entries and return discoverable accounts.""" - accounts = load_accounts(session_root) - retained = { - username: entry - for username, entry in accounts.items() - if _is_discoverable(entry, keyring_has) - } - if retained != accounts: - _save_accounts(session_root, retained) + with _locked_index(session_root) as index_path: + accounts = _load_accounts_from_path(index_path) + retained = { + username: entry + for username, entry in accounts.items() + if _is_discoverable(entry, keyring_has) + } + if retained != accounts: + _save_accounts_to_path(index_path, retained) return [retained[username] for username in sorted(retained)] @@ -122,20 +177,23 @@ def remember_account( ) -> AccountIndexEntry: """Upsert one account entry and prune any stale neighbors.""" - accounts = { - entry["username"]: entry for entry in prune_accounts(session_root, keyring_has) - } - previous = accounts.get(username) - entry: AccountIndexEntry = { - "username": username, - "last_used_at": datetime.now(tz=timezone.utc).isoformat(), - "session_path": session_path, - "cookiejar_path": cookiejar_path, - } - if china_mainland is not None: - entry["china_mainland"] = china_mainland - elif previous is not None and "china_mainland" in previous: - entry["china_mainland"] = previous["china_mainland"] - accounts[username] = entry - _save_accounts(session_root, accounts) - return entry + with _locked_index(session_root) as index_path: + accounts = { + username_: entry + for username_, entry in _load_accounts_from_path(index_path).items() + if _is_discoverable(entry, keyring_has) + } + previous = accounts.get(username) + entry: AccountIndexEntry = { + "username": username, + "last_used_at": datetime.now(tz=timezone.utc).isoformat(), + "session_path": session_path, + "cookiejar_path": cookiejar_path, + } + if china_mainland is not None: + entry["china_mainland"] = china_mainland + elif previous is not None and "china_mainland" in previous: + entry["china_mainland"] = previous["china_mainland"] + accounts[username] = entry + _save_accounts_to_path(index_path, accounts) + return entry diff --git a/pyicloud/cli/app.py b/pyicloud/cli/app.py index 71923b62..9bb5ae39 100644 --- a/pyicloud/cli/app.py +++ b/pyicloud/cli/app.py @@ -29,15 +29,30 @@ def _group_root(ctx: typer.Context) -> None: raise typer.Exit() -app.add_typer(account_app, name="account", invoke_without_command=True, callback=_group_root) +app.add_typer( + account_app, name="account", invoke_without_command=True, callback=_group_root +) app.add_typer(auth_app, name="auth", invoke_without_command=True, callback=_group_root) -app.add_typer(devices_app, name="devices", invoke_without_command=True, callback=_group_root) -app.add_typer(calendar_app, name="calendar", invoke_without_command=True, callback=_group_root) -app.add_typer(contacts_app, name="contacts", invoke_without_command=True, callback=_group_root) -app.add_typer(drive_app, name="drive", invoke_without_command=True, callback=_group_root) -app.add_typer(photos_app, name="photos", invoke_without_command=True, callback=_group_root) app.add_typer( - hidemyemail_app, name="hidemyemail", invoke_without_command=True, callback=_group_root + devices_app, name="devices", invoke_without_command=True, callback=_group_root +) +app.add_typer( + calendar_app, name="calendar", invoke_without_command=True, callback=_group_root +) +app.add_typer( + contacts_app, name="contacts", invoke_without_command=True, callback=_group_root +) +app.add_typer( + drive_app, name="drive", invoke_without_command=True, callback=_group_root +) +app.add_typer( + photos_app, name="photos", invoke_without_command=True, callback=_group_root +) +app.add_typer( + hidemyemail_app, + name="hidemyemail", + invoke_without_command=True, + callback=_group_root, ) diff --git a/pyicloud/cli/commands/account.py b/pyicloud/cli/commands/account.py index ebd1d195..74ded1ae 100644 --- a/pyicloud/cli/commands/account.py +++ b/pyicloud/cli/commands/account.py @@ -11,17 +11,46 @@ normalize_family_member, normalize_storage, ) -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud account metadata.") @app.command("summary") -@with_service_command_options -def account_summary(ctx: typer.Context) -> None: +def account_summary( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Show high-level account information.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() account = service_call( @@ -41,10 +70,28 @@ def account_summary(ctx: typer.Context) -> None: @app.command("devices") -@with_service_command_options -def account_devices(ctx: typer.Context) -> None: +def account_devices( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """List devices associated with the account profile.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -76,10 +123,28 @@ def account_devices(ctx: typer.Context) -> None: @app.command("family") -@with_service_command_options -def account_family(ctx: typer.Context) -> None: +def account_family( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """List family sharing members.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -111,10 +176,28 @@ def account_family(ctx: typer.Context) -> None: @app.command("storage") -@with_service_command_options -def account_storage(ctx: typer.Context) -> None: +def account_storage( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Show iCloud storage usage.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = normalize_storage( diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index 7546199d..d2442b79 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -6,9 +6,20 @@ from pyicloud.cli.context import CLIAbort, get_state from pyicloud.cli.options import ( - with_auth_login_options, - with_auth_session_options, - with_keyring_delete_options, + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + AcceptTermsOption, + ChinaMainlandOption, + HttpProxyOption, + HttpsProxyOption, + InteractiveOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + PasswordOption, + SessionDirOption, + UsernameOption, + store_command_options, ) from pyicloud.cli.output import console_kv_table, console_table @@ -78,10 +89,28 @@ def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: @app.command("status") -@with_auth_session_options -def auth_status(ctx: typer.Context) -> None: +def auth_status( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Show the current authentication and session status.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) if not state.has_explicit_username: active_probes = state.active_session_probes() @@ -151,10 +180,36 @@ def auth_status(ctx: typer.Context) -> None: @app.command("login") -@with_auth_login_options -def auth_login(ctx: typer.Context) -> None: +def auth_login( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + password: PasswordOption = None, + china_mainland: ChinaMainlandOption = None, + interactive: InteractiveOption = True, + accept_terms: AcceptTermsOption = False, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Authenticate and persist a usable session.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + password=password, + china_mainland=china_mainland, + interactive=interactive, + accept_terms=accept_terms, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_login_api() payload = _auth_payload( @@ -185,7 +240,6 @@ def auth_login(ctx: typer.Context) -> None: @app.command("logout") -@with_auth_session_options def auth_logout( ctx: typer.Context, keep_trusted: bool = typer.Option( @@ -203,9 +257,26 @@ def auth_logout( "--remove-keyring", help="Delete the stored password for the selected account after logout.", ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Log out and clear local session persistence.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) if state.has_explicit_username: api = state.get_probe_api() @@ -266,10 +337,22 @@ def auth_logout( @keyring_app.command("delete") -@with_keyring_delete_options -def auth_keyring_delete(ctx: typer.Context) -> None: +def auth_keyring_delete( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Delete a stored keyring password.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) if not state.has_explicit_username: raise CLIAbort("The --username option is required for auth keyring delete.") diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py index 0a6c8ebc..acb1901d 100644 --- a/pyicloud/cli/commands/calendar.py +++ b/pyicloud/cli/commands/calendar.py @@ -9,17 +9,46 @@ from pyicloud.cli.context import get_state, parse_datetime, service_call from pyicloud.cli.normalize import normalize_calendar, normalize_event -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect calendars and events.") @app.command("calendars") -@with_service_command_options -def calendar_calendars(ctx: typer.Context) -> None: +def calendar_calendars( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """List available calendars.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -47,7 +76,6 @@ def calendar_calendars(ctx: typer.Context) -> None: @app.command("events") -@with_service_command_options def calendar_events( ctx: typer.Context, from_dt: Optional[str] = typer.Option(None, "--from", help="Start datetime."), @@ -57,9 +85,26 @@ def calendar_events( None, "--calendar-guid", help="Only show events from one calendar." ), limit: int = typer.Option(50, "--limit", min=1, help="Maximum events to show."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """List calendar events.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ diff --git a/pyicloud/cli/commands/contacts.py b/pyicloud/cli/commands/contacts.py index 908cd9c3..bcd6fec5 100644 --- a/pyicloud/cli/commands/contacts.py +++ b/pyicloud/cli/commands/contacts.py @@ -8,20 +8,47 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_contact, normalize_me -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Inspect iCloud contacts.") @app.command("list") -@with_service_command_options def contacts_list( ctx: typer.Context, limit: int = typer.Option(50, "--limit", min=1, help="Maximum contacts to show."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """List contacts.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -52,10 +79,28 @@ def contacts_list( @app.command("me") -@with_service_command_options -def contacts_me(ctx: typer.Context) -> None: +def contacts_me( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Show the signed-in contact card.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = normalize_me(service_call("Contacts", lambda: api.contacts.me)) diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 9377fa81..a659c7b6 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -8,7 +8,19 @@ from pyicloud.cli.context import get_state, resolve_device, service_call from pyicloud.cli.normalize import normalize_device_details, normalize_device_summary -from pyicloud.cli.options import with_devices_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + WithFamilyOption, + store_command_options, +) from pyicloud.cli.output import ( console_kv_table, console_table, @@ -20,15 +32,33 @@ @app.command("list") -@with_devices_command_options def devices_list( ctx: typer.Context, locate: bool = typer.Option( False, "--locate", help="Fetch current device locations." ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """List Find My devices.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -62,7 +92,6 @@ def devices_list( @app.command("show") -@with_devices_command_options def devices_show( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -70,9 +99,28 @@ def devices_show( False, "--locate", help="Fetch current device location." ), raw: bool = typer.Option(False, "--raw", help="Show the raw device payload."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Show detailed information for one device.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device) @@ -100,18 +148,40 @@ def devices_show( @app.command("sound") -@with_devices_command_options def devices_sound( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), subject: str = typer.Option("Find My iPhone Alert", "--subject"), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Play a sound on a device.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device) - idevice.play_sound(subject=subject) + service_call( + "Find My", + lambda: idevice.play_sound(subject=subject), + account_name=api.account_name, + ) payload = {"device_id": idevice.id, "subject": subject} if state.json_output: state.write_json(payload) @@ -120,20 +190,44 @@ def devices_sound( @app.command("message") -@with_devices_command_options def devices_message( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), message: str = typer.Argument(..., help="Message to display."), subject: str = typer.Option("A Message", "--subject"), silent: bool = typer.Option(False, "--silent", help="Do not play a sound."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Display a message on a device.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device) - idevice.display_message(subject=subject, message=message, sounds=not silent) + service_call( + "Find My", + lambda: idevice.display_message( + subject=subject, message=message, sounds=not silent + ), + account_name=api.account_name, + ) payload = { "device_id": idevice.id, "subject": subject, @@ -147,7 +241,6 @@ def devices_message( @app.command("lost-mode") -@with_devices_command_options def devices_lost_mode( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -158,13 +251,36 @@ def devices_lost_mode( help="Lost mode message.", ), passcode: str = typer.Option("", "--passcode", help="New device passcode."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Enable lost mode for a device.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() - idevice = resolve_device(api, device) - idevice.lost_device(number=phone, text=message, newpasscode=passcode) + idevice = resolve_device(api, device, require_unique=True) + service_call( + "Find My", + lambda: idevice.lost_device(number=phone, text=message, newpasscode=passcode), + account_name=api.account_name, + ) payload = { "device_id": idevice.id, "phone": phone, @@ -178,7 +294,6 @@ def devices_lost_mode( @app.command("erase") -@with_devices_command_options def devices_erase( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -186,13 +301,36 @@ def devices_erase( "This iPhone has been lost. Please call me.", "--message", ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Request a remote erase for a device.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() - idevice = resolve_device(api, device) - idevice.erase_device(message) + idevice = resolve_device(api, device, require_unique=True) + service_call( + "Find My", + lambda: idevice.erase_device(message), + account_name=api.account_name, + ) payload = {"device_id": idevice.id, "message": message} if state.json_output: state.write_json(payload) @@ -201,7 +339,6 @@ def devices_erase( @app.command("export") -@with_devices_command_options def devices_export( ctx: typer.Context, device: str = typer.Argument(..., help="Device id or name."), @@ -217,9 +354,28 @@ def devices_export( hidden=True, help="Write normalized device fields instead of the raw payload.", ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, + with_family: WithFamilyOption = False, ) -> None: """Export a device snapshot to JSON.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + with_family=with_family, + ) state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device) diff --git a/pyicloud/cli/commands/drive.py b/pyicloud/cli/commands/drive.py index 242d7645..49dba136 100644 --- a/pyicloud/cli/commands/drive.py +++ b/pyicloud/cli/commands/drive.py @@ -14,27 +14,63 @@ write_response_to_path, ) from pyicloud.cli.normalize import normalize_drive_node -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Drive files.") +def _resolve_drive_node_or_abort(drive, path: str, *, trash: bool = False): + """Resolve a drive path or raise a user-facing CLI error.""" + + try: + return resolve_drive_node(drive, path, trash=trash) + except KeyError as err: + raise CLIAbort(f"Path not found: {path}") from err + + @app.command("list") -@with_service_command_options def drive_list( ctx: typer.Context, path: str = typer.Argument("/", help="Drive path, for example /Documents."), trash: bool = typer.Option( False, "--trash", help="Resolve the path from the trash root." ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """List a drive folder or inspect a file.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() - drive = service_call("Drive", lambda: api.drive) - node = resolve_drive_node(drive, path, trash=trash) + drive = service_call("Drive", lambda: api.drive, account_name=api.account_name) + node = _resolve_drive_node_or_abort(drive, path, trash=trash) if node.type == "file": payload = normalize_drive_node(node) if state.json_output: @@ -73,7 +109,6 @@ def drive_list( @app.command("download") -@with_service_command_options def drive_download( ctx: typer.Context, path: str = typer.Argument(..., help="Drive path to the file."), @@ -81,13 +116,30 @@ def drive_download( trash: bool = typer.Option( False, "--trash", help="Resolve the path from the trash root." ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Download a Drive file.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() - drive = service_call("Drive", lambda: api.drive) - node = resolve_drive_node(drive, path, trash=trash) + drive = service_call("Drive", lambda: api.drive, account_name=api.account_name) + node = _resolve_drive_node_or_abort(drive, path, trash=trash) if node.type != "file": raise CLIAbort("Only files can be downloaded.") response = node.open(stream=True) diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index 7e1abe0d..9e6229d8 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -6,17 +6,46 @@ from pyicloud.cli.context import get_state, service_call from pyicloud.cli.normalize import normalize_alias -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Manage Hide My Email aliases.") @app.command("list") -@with_service_command_options -def hidemyemail_list(ctx: typer.Context) -> None: +def hidemyemail_list( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """List Hide My Email aliases.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = [ @@ -39,10 +68,28 @@ def hidemyemail_list(ctx: typer.Context) -> None: @app.command("generate") -@with_service_command_options -def hidemyemail_generate(ctx: typer.Context) -> None: +def hidemyemail_generate( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """Generate a new relay address.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() alias = service_call("Hide My Email", lambda: api.hidemyemail.generate()) @@ -54,15 +101,31 @@ def hidemyemail_generate(ctx: typer.Context) -> None: @app.command("reserve") -@with_service_command_options def hidemyemail_reserve( ctx: typer.Context, email: str = typer.Argument(...), label: str = typer.Argument(...), note: str = typer.Option("Generated", "--note", help="Alias note."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Reserve a generated relay address with metadata.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = service_call( @@ -76,15 +139,31 @@ def hidemyemail_reserve( @app.command("update") -@with_service_command_options def hidemyemail_update( ctx: typer.Context, anonymous_id: str = typer.Argument(...), label: str = typer.Argument(...), note: str = typer.Option("Generated", "--note", help="Alias note."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Update alias metadata.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = service_call( @@ -98,12 +177,29 @@ def hidemyemail_update( @app.command("deactivate") -@with_service_command_options def hidemyemail_deactivate( - ctx: typer.Context, anonymous_id: str = typer.Argument(...) + ctx: typer.Context, + anonymous_id: str = typer.Argument(...), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Deactivate an alias.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = service_call( @@ -116,12 +212,29 @@ def hidemyemail_deactivate( @app.command("reactivate") -@with_service_command_options def hidemyemail_reactivate( - ctx: typer.Context, anonymous_id: str = typer.Argument(...) + ctx: typer.Context, + anonymous_id: str = typer.Argument(...), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Reactivate an alias.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = service_call( @@ -134,12 +247,29 @@ def hidemyemail_reactivate( @app.command("delete") -@with_service_command_options def hidemyemail_delete( - ctx: typer.Context, anonymous_id: str = typer.Argument(...) + ctx: typer.Context, + anonymous_id: str = typer.Argument(...), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Delete an alias.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() payload = service_call( diff --git a/pyicloud/cli/commands/photos.py b/pyicloud/cli/commands/photos.py index dc1b06ad..2ba88137 100644 --- a/pyicloud/cli/commands/photos.py +++ b/pyicloud/cli/commands/photos.py @@ -10,21 +10,57 @@ from pyicloud.cli.context import CLIAbort, get_state, service_call from pyicloud.cli.normalize import normalize_album, normalize_photo -from pyicloud.cli.options import with_service_command_options +from pyicloud.cli.options import ( + DEFAULT_LOG_LEVEL, + DEFAULT_OUTPUT_FORMAT, + HttpProxyOption, + HttpsProxyOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + SessionDirOption, + UsernameOption, + store_command_options, +) from pyicloud.cli.output import console_table app = typer.Typer(help="Browse and download iCloud Photos.") @app.command("albums") -@with_service_command_options -def photos_albums(ctx: typer.Context) -> None: +def photos_albums( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: """List photo albums.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() - photos = service_call("Photos", lambda: api.photos) - payload = [normalize_album(album) for album in photos.albums] + photos = service_call("Photos", lambda: api.photos, account_name=api.account_name) + payload = [ + normalize_album(album) + for album in service_call( + "Photos", + lambda: list(photos.albums), + account_name=api.account_name, + ) + ] if state.json_output: state.write_json(payload) return @@ -38,24 +74,55 @@ def photos_albums(ctx: typer.Context) -> None: @app.command("list") -@with_service_command_options def photos_list( ctx: typer.Context, album: Optional[str] = typer.Option( None, "--album", help="Album name. Defaults to all photos." ), limit: int = typer.Option(50, "--limit", min=1, help="Maximum photos to show."), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """List photo assets.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() - photos = service_call("Photos", lambda: api.photos) - album_obj = photos.albums.find(album) if album else photos.all + photos = service_call("Photos", lambda: api.photos, account_name=api.account_name) + album_obj = service_call( + "Photos", + lambda: photos.albums.find(album) if album else photos.all, + account_name=api.account_name, + ) if album and album_obj is None: raise CLIAbort(f"No album named '{album}' was found.") - photos_iter = album_obj.photos if album_obj is not None else photos.all.photos - payload = [normalize_photo(item) for item in islice(photos_iter, limit)] + payload = [ + normalize_photo(item) + for item in service_call( + "Photos", + lambda: list( + islice( + album_obj.photos if album_obj is not None else photos.all.photos, + limit, + ) + ), + account_name=api.account_name, + ) + ] if state.json_output: state.write_json(payload) return @@ -78,7 +145,6 @@ def photos_list( @app.command("download") -@with_service_command_options def photos_download( ctx: typer.Context, photo_id: str = typer.Argument(..., help="Photo asset id."), @@ -86,14 +152,42 @@ def photos_download( version: str = typer.Option( "original", "--version", help="Photo version to download." ), + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, ) -> None: """Download a photo asset.""" + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) state = get_state(ctx) api = state.get_api() - photos = service_call("Photos", lambda: api.photos) - photo = photos.all[photo_id] - data = photo.download(version=version) + photos = service_call("Photos", lambda: api.photos, account_name=api.account_name) + try: + photo = service_call( + "Photos", + lambda: photos.all[photo_id], + account_name=api.account_name, + ) + except KeyError as err: + raise CLIAbort(f"No photo matched '{photo_id}'.") from err + data = service_call( + "Photos", + lambda: photo.download(version=version), + account_name=api.account_name, + ) if data is None: raise CLIAbort("No data was returned for that photo version.") output.parent.mkdir(parents=True, exist_ok=True) diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index 3cfcad6a..b4c17ac6 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -352,12 +352,12 @@ def _handle_2sa(self, api: PyiCloudService) -> None: self.console.print(f" {index}: {label}") selected_index = self._prompt_index("Select trusted device index", len(devices)) device = devices[selected_index] - if not api.send_verification_code(device): - raise CLIAbort("Failed to send the 2SA verification code.") if not self.interactive: raise CLIAbort( "Two-step authentication is required, but interactive prompts are disabled." ) + if not api.send_verification_code(device): + raise CLIAbort("Failed to send the 2SA verification code.") code = typer.prompt("Enter 2SA verification code") if not api.validate_verification_code(device, code): raise CLIAbort("Failed to verify the 2SA code.") @@ -597,21 +597,50 @@ def parse_datetime(value: Optional[str]) -> Optional[datetime]: return dt -def resolve_device(api: PyiCloudService, query: str): +def resolve_device(api: PyiCloudService, query: str, *, require_unique: bool = False): """Return a device matched by id or common display names.""" lowered = query.strip().lower() - for device in service_call( - "Find My", lambda: api.devices, account_name=api.account_name - ): + devices = list( + service_call("Find My", lambda: api.devices, account_name=api.account_name) + ) + for device in devices: + identifier = str(getattr(device, "id", "")).strip().lower() + if identifier and identifier == lowered: + return device + + matches = [] + seen_ids: set[str] = set() + for device in devices: + identifier = str(getattr(device, "id", "")).strip() candidates = [ - getattr(device, "id", ""), getattr(device, "name", ""), getattr(device, "deviceDisplayName", ""), ] - if any(str(candidate).strip().lower() == lowered for candidate in candidates): - return device - raise CLIAbort(f"No device matched '{query}'.") + if not any( + str(candidate).strip().lower() == lowered for candidate in candidates + ): + continue + dedupe_key = identifier or str(id(device)) + if dedupe_key in seen_ids: + continue + seen_ids.add(dedupe_key) + matches.append(device) + + if not matches: + raise CLIAbort(f"No device matched '{query}'.") + if require_unique and len(matches) > 1: + options = "\n".join( + " - " + f"{getattr(device, 'id', '')} " + f"({getattr(device, 'name', '')} / " + f"{getattr(device, 'deviceDisplayName', '')})" + for device in matches + ) + raise CLIAbort( + f"Multiple devices matched '{query}'. Use a device id instead.\n{options}" + ) + return matches[0] def resolve_drive_node(drive, path: str, *, trash: bool = False): diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py index 4a9a0968..de06d5f7 100644 --- a/pyicloud/cli/options.py +++ b/pyicloud/cli/options.py @@ -1,19 +1,14 @@ -"""Shared Typer option profiles for CLI leaf commands.""" +"""Shared Typer option aliases and helpers for CLI leaf commands.""" from __future__ import annotations -import inspect -from functools import wraps -from typing import Any, Callable, TypeVar +from typing import Annotated -import click import typer from .context import COMMAND_OPTIONS_META_KEY, LogLevel from .output import OutputFormat -CommandCallback = TypeVar("CommandCallback", bound=Callable[..., Any]) - ACCOUNT_CONTEXT_PANEL = "Account Context" AUTHENTICATION_PANEL = "Authentication" NETWORK_PANEL = "Network" @@ -36,250 +31,138 @@ LOG_LEVEL_OPTION_HELP = "Logging level for pyicloud internals." OUTPUT_FORMAT_OPTION_HELP = "Output format for command results." -PROFILE_ACCOUNT_CONTEXT = "account_context" -PROFILE_AUTHENTICATION = "authentication" -PROFILE_NETWORK = "network" -PROFILE_OUTPUT_DIAGNOSTICS = "output_diagnostics" -PROFILE_DEVICES = "devices" - - -def _parameter( - name: str, - annotation: Any, - default: Any, -) -> inspect.Parameter: - return inspect.Parameter( - name, - inspect.Parameter.KEYWORD_ONLY, - annotation=annotation, - default=default, - ) - - -def _account_context_parameters() -> list[inspect.Parameter]: - return [ - _parameter( - "username", - str | None, - typer.Option( - None, - "--username", - help=USERNAME_OPTION_HELP, - rich_help_panel=ACCOUNT_CONTEXT_PANEL, - ), - ), - _parameter( - "session_dir", - str | None, - typer.Option( - None, - "--session-dir", - help=SESSION_DIR_OPTION_HELP, - rich_help_panel=ACCOUNT_CONTEXT_PANEL, - ), - ), - ] - - -def _authentication_parameters() -> list[inspect.Parameter]: - return [ - _parameter( - "password", - str | None, - typer.Option( - None, - "--password", - help=PASSWORD_OPTION_HELP, - rich_help_panel=AUTHENTICATION_PANEL, - ), - ), - _parameter( - "china_mainland", - bool | None, - typer.Option( - None, - "--china-mainland", - help=CHINA_MAINLAND_OPTION_HELP, - rich_help_panel=AUTHENTICATION_PANEL, - ), - ), - _parameter( - "interactive", - bool, - typer.Option( - True, - "--interactive/--non-interactive", - help=INTERACTIVE_OPTION_HELP, - rich_help_panel=AUTHENTICATION_PANEL, - ), - ), - _parameter( - "accept_terms", - bool, - typer.Option( - False, - "--accept-terms", - help=ACCEPT_TERMS_OPTION_HELP, - rich_help_panel=AUTHENTICATION_PANEL, - ), - ), - ] - - -def _network_parameters() -> list[inspect.Parameter]: - return [ - _parameter( - "http_proxy", - str | None, - typer.Option( - None, - "--http-proxy", - help=HTTP_PROXY_OPTION_HELP, - rich_help_panel=NETWORK_PANEL, - ), - ), - _parameter( - "https_proxy", - str | None, - typer.Option( - None, - "--https-proxy", - help=HTTPS_PROXY_OPTION_HELP, - rich_help_panel=NETWORK_PANEL, - ), - ), - _parameter( - "no_verify_ssl", - bool, - typer.Option( - False, - "--no-verify-ssl", - help=NO_VERIFY_SSL_OPTION_HELP, - rich_help_panel=NETWORK_PANEL, - ), - ), - ] - - -def _output_diagnostics_parameters() -> list[inspect.Parameter]: - return [ - _parameter( - "output_format", - OutputFormat, - typer.Option( - OutputFormat.TEXT, - "--format", - case_sensitive=False, - help=OUTPUT_FORMAT_OPTION_HELP, - rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, - ), - ), - _parameter( - "log_level", - LogLevel, - typer.Option( - LogLevel.WARNING, - "--log-level", - case_sensitive=False, - help=LOG_LEVEL_OPTION_HELP, - rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, - ), - ), - ] - - -def _device_parameters() -> list[inspect.Parameter]: - return [ - _parameter( - "with_family", - bool, - typer.Option( - False, - "--with-family", - help=WITH_FAMILY_OPTION_HELP, - rich_help_panel=DEVICES_PANEL, - ), - ), - ] - - -PROFILE_FACTORIES: dict[str, Callable[[], list[inspect.Parameter]]] = { - PROFILE_ACCOUNT_CONTEXT: _account_context_parameters, - PROFILE_AUTHENTICATION: _authentication_parameters, - PROFILE_NETWORK: _network_parameters, - PROFILE_OUTPUT_DIAGNOSTICS: _output_diagnostics_parameters, - PROFILE_DEVICES: _device_parameters, -} - - -def _profile_parameters(*profiles: str) -> list[inspect.Parameter]: - parameters: list[inspect.Parameter] = [] - seen: set[str] = set() - for profile in profiles: - for parameter in PROFILE_FACTORIES[profile](): - if parameter.name in seen: - continue - seen.add(parameter.name) - parameters.append(parameter) - return parameters - - -def with_option_profiles(*profiles: str) -> Callable[[CommandCallback], CommandCallback]: - """Inject the given command-local option profiles onto a leaf command.""" - - def decorator(fn: CommandCallback) -> CommandCallback: - signature = inspect.signature(fn) - extra_parameters = _profile_parameters(*profiles) - parameter_names = [parameter.name for parameter in extra_parameters] - - @wraps(fn) - def wrapper(*args: Any, **kwargs: Any): - ctx = kwargs.get("ctx") - if ctx is None: - ctx = next( - (arg for arg in args if isinstance(arg, click.Context)), - None, - ) - if ctx is None: - raise RuntimeError("CLI context was not provided.") - - command_options = ctx.meta.setdefault(COMMAND_OPTIONS_META_KEY, {}) - for name in parameter_names: - if name in kwargs: - command_options[name] = kwargs.pop(name) - return fn(*args, **kwargs) - - wrapper.__signature__ = signature.replace( - parameters=list(signature.parameters.values()) + extra_parameters - ) - return wrapper # type: ignore[return-value] - - return decorator - - -with_auth_login_options = with_option_profiles( - PROFILE_ACCOUNT_CONTEXT, - PROFILE_AUTHENTICATION, - PROFILE_NETWORK, - PROFILE_OUTPUT_DIAGNOSTICS, -) -with_auth_session_options = with_option_profiles( - PROFILE_ACCOUNT_CONTEXT, - PROFILE_NETWORK, - PROFILE_OUTPUT_DIAGNOSTICS, -) -with_service_command_options = with_option_profiles( - PROFILE_ACCOUNT_CONTEXT, - PROFILE_NETWORK, - PROFILE_OUTPUT_DIAGNOSTICS, -) -with_devices_command_options = with_option_profiles( - PROFILE_ACCOUNT_CONTEXT, - PROFILE_NETWORK, - PROFILE_OUTPUT_DIAGNOSTICS, - PROFILE_DEVICES, -) -with_keyring_delete_options = with_option_profiles( - PROFILE_ACCOUNT_CONTEXT, - PROFILE_OUTPUT_DIAGNOSTICS, -) +DEFAULT_LOG_LEVEL = LogLevel.WARNING +DEFAULT_OUTPUT_FORMAT = OutputFormat.TEXT + +UsernameOption = Annotated[ + str | None, + typer.Option( + "--username", + help=USERNAME_OPTION_HELP, + rich_help_panel=ACCOUNT_CONTEXT_PANEL, + ), +] +SessionDirOption = Annotated[ + str | None, + typer.Option( + "--session-dir", + help=SESSION_DIR_OPTION_HELP, + rich_help_panel=ACCOUNT_CONTEXT_PANEL, + ), +] +PasswordOption = Annotated[ + str | None, + typer.Option( + "--password", + help=PASSWORD_OPTION_HELP, + rich_help_panel=AUTHENTICATION_PANEL, + ), +] +ChinaMainlandOption = Annotated[ + bool | None, + typer.Option( + "--china-mainland", + help=CHINA_MAINLAND_OPTION_HELP, + rich_help_panel=AUTHENTICATION_PANEL, + ), +] +InteractiveOption = Annotated[ + bool, + typer.Option( + "--interactive/--non-interactive", + help=INTERACTIVE_OPTION_HELP, + rich_help_panel=AUTHENTICATION_PANEL, + ), +] +AcceptTermsOption = Annotated[ + bool, + typer.Option( + "--accept-terms", + help=ACCEPT_TERMS_OPTION_HELP, + rich_help_panel=AUTHENTICATION_PANEL, + ), +] +HttpProxyOption = Annotated[ + str | None, + typer.Option( + "--http-proxy", + help=HTTP_PROXY_OPTION_HELP, + rich_help_panel=NETWORK_PANEL, + ), +] +HttpsProxyOption = Annotated[ + str | None, + typer.Option( + "--https-proxy", + help=HTTPS_PROXY_OPTION_HELP, + rich_help_panel=NETWORK_PANEL, + ), +] +NoVerifySslOption = Annotated[ + bool, + typer.Option( + "--no-verify-ssl", + help=NO_VERIFY_SSL_OPTION_HELP, + rich_help_panel=NETWORK_PANEL, + ), +] +OutputFormatOption = Annotated[ + OutputFormat, + typer.Option( + "--format", + case_sensitive=False, + help=OUTPUT_FORMAT_OPTION_HELP, + rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, + ), +] +LogLevelOption = Annotated[ + LogLevel, + typer.Option( + "--log-level", + case_sensitive=False, + help=LOG_LEVEL_OPTION_HELP, + rich_help_panel=OUTPUT_DIAGNOSTICS_PANEL, + ), +] +WithFamilyOption = Annotated[ + bool, + typer.Option( + "--with-family", + help=WITH_FAMILY_OPTION_HELP, + rich_help_panel=DEVICES_PANEL, + ), +] + + +def store_command_options( + ctx: typer.Context, + *, + username: str | None = None, + password: str | None = None, + china_mainland: bool | None = None, + interactive: bool = True, + accept_terms: bool = False, + with_family: bool = False, + session_dir: str | None = None, + http_proxy: str | None = None, + https_proxy: str | None = None, + no_verify_ssl: bool = False, + log_level: LogLevel = DEFAULT_LOG_LEVEL, + output_format: OutputFormat = DEFAULT_OUTPUT_FORMAT, +) -> None: + """Persist leaf-command options into the Typer context for CLI state setup.""" + + ctx.meta[COMMAND_OPTIONS_META_KEY] = { + "username": username, + "password": password, + "china_mainland": china_mainland, + "interactive": interactive, + "accept_terms": accept_terms, + "with_family": with_family, + "session_dir": session_dir, + "http_proxy": http_proxy, + "https_proxy": https_proxy, + "no_verify_ssl": no_verify_ssl, + "log_level": log_level, + "output_format": output_format, + } diff --git a/pyicloud/session.py b/pyicloud/session.py index 235f25b0..b696bfd9 100644 --- a/pyicloud/session.py +++ b/pyicloud/session.py @@ -116,8 +116,13 @@ def clear_persistence(self, remove_files: bool = True) -> None: try: cast(PyiCloudCookieJar, self.cookies).clear() - except (KeyError, RuntimeError): - pass + except (KeyError, RuntimeError) as exc: + self._logger.warning( + "Failed to clear cookie jar %s: %s; resetting in-memory cookie jar", + self.cookiejar_path, + exc, + ) + self.cookies = PyiCloudCookieJar(filename=self.cookiejar_path) self._data = {} diff --git a/tests/test_base.py b/tests/test_base.py index d0d39015..92f95d79 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -13,6 +13,7 @@ from requests import HTTPError, Response from pyicloud import PyiCloudService +from pyicloud.cookie_jar import PyiCloudCookieJar from pyicloud.exceptions import ( PyiCloud2SARequiredException, PyiCloudAcceptTermsException, @@ -441,6 +442,23 @@ def test_clear_persistence_removes_session_and_cookie_files( } +def test_clear_persistence_replaces_cookiejar_after_clear_failure( + pyicloud_session: PyiCloudSession, +) -> None: + """Cookie clear failures should reset the in-memory jar before cleanup continues.""" + + broken_cookie_jar = MagicMock() + broken_cookie_jar.clear.side_effect = RuntimeError("boom") + pyicloud_session.cookies = broken_cookie_jar + + with patch("pyicloud.session.os.remove"): + pyicloud_session.clear_persistence() + + broken_cookie_jar.clear.assert_called_once_with() + assert isinstance(pyicloud_session.cookies, PyiCloudCookieJar) + assert pyicloud_session.cookies.filename == pyicloud_session.cookiejar_path + + def test_requires_2sa_property(pyicloud_service: PyiCloudService) -> None: """Test the requires_2sa property.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 2}} diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index a4ddc756..9a3cb2fe 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 +import click from typer.testing import CliRunner account_index_module = importlib.import_module("pyicloud.cli.account_index") @@ -349,6 +350,10 @@ def _runner() -> CliRunner: return CliRunner() +def _plain_output(result: Any) -> str: + return click.unstyle(result.output) + + def _unique_session_dir(label: str = "session") -> Path: path = TEST_ROOT / f"{label}-{uuid4().hex}" path.mkdir(parents=True, exist_ok=True) @@ -494,14 +499,13 @@ def test_root_help() -> None: """The root command should expose only help/completion utilities and subcommands.""" result = _runner().invoke(app, ["--help"]) + text = _plain_output(result) assert result.exit_code == 0 - assert "--username" not in result.stdout - assert "--password" not in result.stdout - assert "--format" not in result.stdout - assert "--session-dir" not in result.stdout - assert "--http-proxy" not in result.stdout - assert "--install-completion" in result.stdout - assert "--show-completion" in result.stdout + assert "--username" not in text + assert "--password" not in text + assert "--format" not in text + assert "--session-dir" not in text + assert "--http-proxy" not in text for command in ( "account", "auth", @@ -512,7 +516,7 @@ def test_root_help() -> None: "photos", "hidemyemail", ): - assert command in result.stdout + assert command in text def test_group_help() -> None: @@ -546,45 +550,49 @@ def test_bare_group_invocation_shows_help() -> None: "hidemyemail", ): result = _runner().invoke(app, [command]) + text = _plain_output(result) assert result.exit_code == 0 - assert "Usage:" in result.stdout - assert "Missing command" not in (result.stdout + result.stderr) + assert "Usage:" in text + assert "Missing command" not in text def test_leaf_help_includes_execution_context_options() -> None: """Leaf command help should show the command-local options it supports.""" result = _runner().invoke(app, ["account", "summary", "--help"]) + text = _plain_output(result) assert result.exit_code == 0 - assert "--username" in result.stdout - assert "--format" in result.stdout - assert "--session-dir" in result.stdout - assert "--password" not in result.stdout - assert "--with-family" not in result.stdout + assert "--username" in text + assert "--format" in text + assert "--session-dir" in text + assert "--password" not in text + assert "--with-family" not in text def test_auth_login_help_scopes_authentication_options() -> None: """Auth login help should expose auth-only options on the leaf command.""" result = _runner().invoke(app, ["auth", "login", "--help"]) + text = _plain_output(result) assert result.exit_code == 0 - assert "--username" in result.stdout - assert "--password" in result.stdout - assert "--china-mainland" in result.stdout - assert "--interactive" in result.stdout - assert "--accept-terms" in result.stdout - assert "--with-family" not in result.stdout + assert "--username" in text + assert "--password" in text + assert "--china-mainland" in text + assert "--interactive" in text + assert "--accept-terms" in text + assert "--with-family" not in text def test_devices_help_scopes_device_options() -> None: """Devices help should expose device-specific options on device commands only.""" result = _runner().invoke(app, ["devices", "list", "--help"]) + text = _plain_output(result) assert result.exit_code == 0 - assert "--with-family" in result.stdout + assert "--with-family" in text def test_account_summary_command() -> None: @@ -641,7 +649,7 @@ def test_old_root_execution_options_fail_cleanly() -> None: ): result = _runner().invoke(app, cli_args) assert result.exit_code != 0 - assert "No such option" in (result.stdout + result.stderr) + assert "No such option" in _plain_output(result) def test_auth_login_accepts_command_local_username() -> None: @@ -733,15 +741,15 @@ def test_china_mainland_is_login_only() -> None: status_result = _runner().invoke(app, ["auth", "status", "--china-mainland"]) service_result = _runner().invoke(app, ["account", "summary", "--china-mainland"]) + status_text = _plain_output(status_result) + service_text = _plain_output(service_result) assert status_result.exit_code != 0 - assert "No such option: --china-mainland" in ( - status_result.stdout + status_result.stderr - ) + assert "No such option" in status_text + assert "--china-mainland" in status_text assert service_result.exit_code != 0 - assert "No such option: --china-mainland" in ( - service_result.stdout + service_result.stderr - ) + assert "No such option" in service_text + assert "--china-mainland" in service_text def test_auth_login_persists_china_mainland_metadata() -> None: @@ -1283,8 +1291,9 @@ def test_multiple_local_accounts_require_explicit_username_for_auth_login() -> N patch.object( context_module.utils, "password_exists_in_keyring", - side_effect=lambda candidate: candidate - in {"alpha@example.com", "beta@example.com"}, + side_effect=lambda candidate: ( + candidate in {"alpha@example.com", "beta@example.com"} + ), ), ): result = _runner().invoke( @@ -1428,6 +1437,33 @@ def test_account_index_prunes_stale_entries_but_keeps_keyring_backed_accounts() assert kept_api.session.session_path.endswith("keptexamplecom.session") +def test_account_index_save_is_atomic() -> None: + """Account index writes should use an atomic replace into accounts.json.""" + + session_dir = _unique_session_dir("index-atomic") + accounts = { + "user@example.com": { + "username": "user@example.com", + "last_used_at": "2026-03-18T00:00:00+00:00", + "session_path": str(session_dir / "userexamplecom.session"), + "cookiejar_path": str(session_dir / "userexamplecom.cookiejar"), + } + } + + with patch.object( + account_index_module.os, + "replace", + wraps=account_index_module.os.replace, + ) as replace: + account_index_module._save_accounts(session_dir, accounts) + + replace.assert_called_once() + assert replace.call_args.args[1] == account_index_module.account_index_path( + session_dir + ) + assert account_index_module.load_accounts(session_dir) == accounts + + def test_auth_login_non_interactive_requires_credentials() -> None: """Auth login should fail cleanly when non-interactive mode lacks credentials.""" @@ -1637,6 +1673,22 @@ def test_trusted_device_2sa_flow() -> None: ) +def test_non_interactive_2sa_does_not_send_verification_code() -> None: + """Non-interactive 2SA should fail before sending a verification code.""" + + fake_api = FakeAPI() + fake_api.requires_2sa = True + fake_api.trusted_devices = [{"deviceName": "Trusted Device", "phoneNumber": "+1"}] + + result = _invoke(fake_api, "auth", "login", interactive=False) + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Two-step authentication is required, but interactive prompts are disabled." + ) + fake_api.send_verification_code.assert_not_called() + + def test_devices_list_and_show_commands() -> None: """Devices list and show should expose summary and detailed views.""" @@ -1802,6 +1854,42 @@ def test_devices_mutations_and_export() -> None: ) +def test_device_mutation_reports_reauthentication_requirement() -> None: + """Mutating Find My commands should surface a clean reauthentication message.""" + + fake_api = FakeAPI() + fake_api.devices[0].play_sound = MagicMock( + side_effect=context_module.PyiCloudFailedLoginException("No password set") + ) + + result = _invoke(fake_api, "devices", "sound", "device-1") + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Find My requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + +def test_destructive_device_commands_require_unique_match() -> None: + """Lost mode should require an unambiguous device name or an explicit device id.""" + + fake_api = FakeAPI() + duplicate = FakeDevice() + duplicate.id = "device-2" + duplicate.data["id"] = duplicate.id + fake_api.devices = [fake_api.devices[0], duplicate] + + result = _invoke(fake_api, "devices", "lost-mode", "Example iPhone") + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Multiple devices matched 'Example iPhone'. Use a device id instead.\n" + " - device-1 (Example iPhone / iPhone)\n" + " - device-2 (Example iPhone / iPhone)" + ) + + def test_calendar_and_contacts_commands() -> None: """Calendar and contacts groups should expose read commands.""" @@ -1847,6 +1935,75 @@ def test_drive_and_photos_commands() -> None: assert json.loads(json_drive_result.stdout)["path"] == str(json_output_path) +def test_drive_missing_paths_report_cli_abort() -> None: + """Drive commands should collapse missing path lookups into CLIAbort errors.""" + + fake_api = FakeAPI() + output_path = Path("/tmp/python-test-results/test_cmdline/missing.txt") + + list_result = _invoke(fake_api, "drive", "list", "/missing") + download_result = _invoke( + fake_api, + "drive", + "download", + "/missing", + "--output", + str(output_path), + ) + + assert list_result.exit_code != 0 + assert list_result.exception.args[0] == "Path not found: /missing" + assert download_result.exit_code != 0 + assert download_result.exception.args[0] == "Path not found: /missing" + + +def test_photos_commands_report_reauthentication_requirement() -> None: + """Photos commands should wrap nested service operations in service_call.""" + + class ReauthAlbums: + @property + def albums(self): + raise context_module.PyiCloudFailedLoginException("No password set") + + fake_api = FakeAPI() + fake_api.photos = ReauthAlbums() + + albums_result = _invoke(fake_api, "photos", "albums") + + assert albums_result.exit_code != 0 + assert albums_result.exception.args[0] == ( + "Photos requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + class BrokenPhoto(FakePhoto): + def download(self, version: str = "original") -> bytes: + raise context_module.PyiCloudFailedLoginException("No password set") + + photo_album = FakePhotoAlbum("All Photos", [BrokenPhoto("photo-1", "img.jpg")]) + fake_api = FakeAPI() + fake_api.photos = SimpleNamespace( + albums=FakeAlbumContainer([photo_album]), + all=photo_album, + ) + output_path = Path("/tmp/python-test-results/test_cmdline/photo-reauth.bin") + + download_result = _invoke( + fake_api, + "photos", + "download", + "photo-1", + "--output", + str(output_path), + ) + + assert download_result.exit_code != 0 + assert download_result.exception.args[0] == ( + "Photos requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + def test_hidemyemail_commands() -> None: """Hide My Email commands should expose list and generate.""" From 9d5a3a67dfa9ab06831ae91f6bdf3c7bf01fbdf0 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 16:35:01 +0100 Subject: [PATCH 14/18] Tighten CLI review follow-ups --- pyicloud/cli/account_index.py | 6 ++++ pyicloud/cli/commands/calendar.py | 11 +++++-- pyicloud/cli/commands/devices.py | 6 ++-- pyicloud/cli/commands/hidemyemail.py | 29 ++++++++++++----- tests/test_cmdline.py | 47 ++++++++++++++++++++++------ 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/pyicloud/cli/account_index.py b/pyicloud/cli/account_index.py index 54ca8b54..05a92532 100644 --- a/pyicloud/cli/account_index.py +++ b/pyicloud/cli/account_index.py @@ -89,6 +89,12 @@ def _locked_index(session_root: str | Path) -> Iterator[Path]: yield index_path finally: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + try: + lock_path.unlink() + except FileNotFoundError: + pass + except OSError: + pass def _save_accounts( diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py index acb1901d..dee66910 100644 --- a/pyicloud/cli/commands/calendar.py +++ b/pyicloud/cli/commands/calendar.py @@ -82,9 +82,16 @@ def calendar_events( to_dt: Optional[str] = typer.Option(None, "--to", help="End datetime."), period: str = typer.Option("month", "--period", help="Calendar period shortcut."), calendar_guid: Optional[str] = typer.Option( - None, "--calendar-guid", help="Only show events from one calendar." + None, + "--calendar-guid", + help="Only show events from one calendar. Filtering is applied client-side.", + ), + limit: int = typer.Option( + 50, + "--limit", + min=1, + help="Maximum events to show after filtering.", ), - limit: int = typer.Option(50, "--limit", min=1, help="Maximum events to show."), username: UsernameOption = None, session_dir: SessionDirOption = None, http_proxy: HttpProxyOption = None, diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index a659c7b6..31fbc74d 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -382,8 +382,10 @@ def devices_export( if raw and normalized: raise typer.BadParameter("Choose either --raw or --normalized, not both.") - use_raw = raw is not False and not normalized - payload = idevice.data if use_raw else normalize_device_details(idevice) + use_raw = raw is True and not normalized + payload = ( + idevice.data if use_raw else normalize_device_details(idevice, locate=False) + ) write_json_file(output, payload) if state.json_output: state.write_json({"device_id": idevice.id, "path": str(output), "raw": use_raw}) diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index 9e6229d8..e761a642 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -48,10 +48,11 @@ def hidemyemail_list( ) state = get_state(ctx) api = state.get_api() - payload = [ - normalize_alias(alias) - for alias in service_call("Hide My Email", lambda: api.hidemyemail) - ] + payload = service_call( + "Hide My Email", + lambda: [normalize_alias(alias) for alias in api.hidemyemail], + account_name=api.account_name, + ) if state.json_output: state.write_json(payload) return @@ -92,7 +93,11 @@ def hidemyemail_generate( ) state = get_state(ctx) api = state.get_api() - alias = service_call("Hide My Email", lambda: api.hidemyemail.generate()) + alias = service_call( + "Hide My Email", + lambda: api.hidemyemail.generate(), + account_name=api.account_name, + ) payload = {"email": alias} if state.json_output: state.write_json(payload) @@ -131,6 +136,7 @@ def hidemyemail_reserve( payload = service_call( "Hide My Email", lambda: api.hidemyemail.reserve(email=email, label=label, note=note), + account_name=api.account_name, ) if state.json_output: state.write_json(payload) @@ -169,6 +175,7 @@ def hidemyemail_update( payload = service_call( "Hide My Email", lambda: api.hidemyemail.update_metadata(anonymous_id, label, note), + account_name=api.account_name, ) if state.json_output: state.write_json(payload) @@ -203,7 +210,9 @@ def hidemyemail_deactivate( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", lambda: api.hidemyemail.deactivate(anonymous_id) + "Hide My Email", + lambda: api.hidemyemail.deactivate(anonymous_id), + account_name=api.account_name, ) if state.json_output: state.write_json(payload) @@ -238,7 +247,9 @@ def hidemyemail_reactivate( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", lambda: api.hidemyemail.reactivate(anonymous_id) + "Hide My Email", + lambda: api.hidemyemail.reactivate(anonymous_id), + account_name=api.account_name, ) if state.json_output: state.write_json(payload) @@ -273,7 +284,9 @@ def hidemyemail_delete( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", lambda: api.hidemyemail.delete(anonymous_id) + "Hide My Email", + lambda: api.hidemyemail.delete(anonymous_id), + account_name=api.account_name, ) if state.json_output: state.write_json(payload) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 9a3cb2fe..e278e80f 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -4,6 +4,7 @@ import importlib import json +import tempfile from contextlib import nullcontext from datetime import datetime, timezone from pathlib import Path @@ -21,7 +22,7 @@ output_module = importlib.import_module("pyicloud.cli.output") app = cli_module.app -TEST_ROOT = Path("/tmp/python-test-results/test_cmdline") +TEST_ROOT = Path(tempfile.gettempdir()) / "python-test-results" / "test_cmdline" class FakeDevice: @@ -1796,7 +1797,7 @@ def test_devices_mutations_and_export() -> None: """Device actions should map to the Find My device methods.""" fake_api = FakeAPI() - export_path = Path("/tmp/python-test-results/test_cmdline/device.json") + export_path = TEST_ROOT / "device.json" export_path.parent.mkdir(parents=True, exist_ok=True) sound_result = _invoke( fake_api, @@ -1848,10 +1849,14 @@ def test_devices_mutations_and_export() -> None: "newpasscode": "4567", } assert export_result.exit_code == 0 - assert json.loads(export_result.stdout)["path"] == str(export_path) - assert ( - json.loads(export_path.read_text(encoding="utf-8"))["name"] == "Example iPhone" - ) + export_payload = json.loads(export_result.stdout) + written_payload = json.loads(export_path.read_text(encoding="utf-8")) + assert export_payload["path"] == str(export_path) + assert export_payload["raw"] is False + assert written_payload["name"] == "Example iPhone" + assert written_payload["display_name"] == "iPhone" + assert "raw_data" in written_payload + assert "deviceDisplayName" not in written_payload def test_device_mutation_reports_reauthentication_requirement() -> None: @@ -1906,8 +1911,8 @@ def test_drive_and_photos_commands() -> None: """Drive and photos commands should expose listing and download flows.""" fake_api = FakeAPI() - output_path = Path("/tmp/python-test-results/test_cmdline/photo.bin") - json_output_path = Path("/tmp/python-test-results/test_cmdline/report.txt") + output_path = TEST_ROOT / "photo.bin" + json_output_path = TEST_ROOT / "report.txt" output_path.parent.mkdir(parents=True, exist_ok=True) drive_result = _invoke(fake_api, "drive", "list", "/") photo_result = _invoke( @@ -1939,7 +1944,7 @@ def test_drive_missing_paths_report_cli_abort() -> None: """Drive commands should collapse missing path lookups into CLIAbort errors.""" fake_api = FakeAPI() - output_path = Path("/tmp/python-test-results/test_cmdline/missing.txt") + output_path = TEST_ROOT / "missing.txt" list_result = _invoke(fake_api, "drive", "list", "/missing") download_result = _invoke( @@ -1986,7 +1991,7 @@ def download(self, version: str = "original") -> bytes: albums=FakeAlbumContainer([photo_album]), all=photo_album, ) - output_path = Path("/tmp/python-test-results/test_cmdline/photo-reauth.bin") + output_path = TEST_ROOT / "photo-reauth.bin" download_result = _invoke( fake_api, @@ -2016,6 +2021,28 @@ def test_hidemyemail_commands() -> None: assert "generated@privaterelay.appleid.com" in generate_result.stdout +def test_hidemyemail_list_reports_reauthentication_requirement() -> None: + """Hide My Email iteration errors should be wrapped in a CLIAbort.""" + + class ReauthHideMyEmail: + def __iter__(self): + raise context_module.PyiCloudFailedLoginException("No password set") + + def generate(self) -> str: # pragma: no cover - not used in this test + return "ignored" + + fake_api = FakeAPI() + fake_api.hidemyemail = ReauthHideMyEmail() + + result = _invoke(fake_api, "hidemyemail", "list") + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Hide My Email requires re-authentication for user@example.com. " + "Run: icloud auth login --username user@example.com" + ) + + def test_main_returns_clean_error_for_user_abort(capsys) -> None: """The entrypoint should not emit a traceback for expected CLI errors.""" From 1dd0b7bd6d932cdbfc55e388a1bdbbe3d21c9685 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 16:58:18 +0100 Subject: [PATCH 15/18] Fix export and alias update edge cases --- pyicloud/cli/commands/devices.py | 2 +- pyicloud/cli/commands/hidemyemail.py | 27 +++++++++--- tests/test_cmdline.py | 65 +++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 31fbc74d..43bcda0d 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -345,7 +345,7 @@ def devices_export( output: Path = typer.Option(..., "--output", help="Destination JSON file."), raw: bool | None = typer.Option( None, - "--raw", + "--raw/--no-raw", help="Write the raw device payload.", ), normalized: bool = typer.Option( diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index e761a642..06339ad2 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -4,7 +4,7 @@ import typer -from pyicloud.cli.context import get_state, service_call +from pyicloud.cli.context import CLIAbort, get_state, service_call from pyicloud.cli.normalize import normalize_alias from pyicloud.cli.options import ( DEFAULT_LOG_LEVEL, @@ -23,6 +23,17 @@ app = typer.Typer(help="Manage Hide My Email aliases.") +def _require_mutation_result(payload: dict, operation: str) -> str: + """Return the alias id from a successful mutator response.""" + + anonymous_id = payload.get("anonymousId") + if isinstance(anonymous_id, str) and anonymous_id: + return anonymous_id + raise CLIAbort( + f"Hide My Email {operation} returned an invalid response: {payload!r}" + ) + + @app.command("list") def hidemyemail_list( ctx: typer.Context, @@ -149,7 +160,7 @@ def hidemyemail_update( ctx: typer.Context, anonymous_id: str = typer.Argument(...), label: str = typer.Argument(...), - note: str = typer.Option("Generated", "--note", help="Alias note."), + note: str | None = typer.Option(None, "--note", help="Alias note."), username: UsernameOption = None, session_dir: SessionDirOption = None, http_proxy: HttpProxyOption = None, @@ -177,10 +188,11 @@ def hidemyemail_update( lambda: api.hidemyemail.update_metadata(anonymous_id, label, note), account_name=api.account_name, ) + updated_id = _require_mutation_result(payload, "update") if state.json_output: state.write_json(payload) return - state.console.print(f"Updated {anonymous_id}") + state.console.print(f"Updated {updated_id}") @app.command("deactivate") @@ -214,10 +226,11 @@ def hidemyemail_deactivate( lambda: api.hidemyemail.deactivate(anonymous_id), account_name=api.account_name, ) + deactivated_id = _require_mutation_result(payload, "deactivate") if state.json_output: state.write_json(payload) return - state.console.print(f"Deactivated {anonymous_id}") + state.console.print(f"Deactivated {deactivated_id}") @app.command("reactivate") @@ -251,10 +264,11 @@ def hidemyemail_reactivate( lambda: api.hidemyemail.reactivate(anonymous_id), account_name=api.account_name, ) + reactivated_id = _require_mutation_result(payload, "reactivate") if state.json_output: state.write_json(payload) return - state.console.print(f"Reactivated {anonymous_id}") + state.console.print(f"Reactivated {reactivated_id}") @app.command("delete") @@ -288,7 +302,8 @@ def hidemyemail_delete( lambda: api.hidemyemail.delete(anonymous_id), account_name=api.account_name, ) + deleted_id = _require_mutation_result(payload, "delete") if state.json_output: state.write_json(payload) return - state.console.print(f"Deleted {anonymous_id}") + state.console.print(f"Deleted {deleted_id}") diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index e278e80f..af8ea315 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -176,9 +176,12 @@ def reserve( return {"anonymousId": "alias-2", "hme": email, "label": label, "note": note} def update_metadata( - self, anonymous_id: str, label: str, note: str + self, anonymous_id: str, label: str, note: Optional[str] ) -> dict[str, Any]: - return {"anonymousId": anonymous_id, "label": label, "note": note} + payload: dict[str, Any] = {"anonymousId": anonymous_id, "label": label} + if note is not None: + payload["note"] = note + return payload def deactivate(self, anonymous_id: str) -> dict[str, Any]: return {"anonymousId": anonymous_id, "active": False} @@ -1858,6 +1861,37 @@ def test_devices_mutations_and_export() -> None: assert "raw_data" in written_payload assert "deviceDisplayName" not in written_payload + raw_export_path = TEST_ROOT / "device-raw.json" + raw_export_result = _invoke( + fake_api, + "devices", + "export", + "device-1", + "--output", + str(raw_export_path), + "--raw", + output_format="json", + ) + no_raw_export_path = TEST_ROOT / "device-no-raw.json" + no_raw_export_result = _invoke( + fake_api, + "devices", + "export", + "device-1", + "--output", + str(no_raw_export_path), + "--no-raw", + output_format="json", + ) + assert raw_export_result.exit_code == 0 + assert json.loads(raw_export_result.stdout)["raw"] is True + assert "deviceDisplayName" in json.loads( + raw_export_path.read_text(encoding="utf-8") + ) + assert no_raw_export_result.exit_code == 0 + assert json.loads(no_raw_export_result.stdout)["raw"] is False + assert "display_name" in json.loads(no_raw_export_path.read_text(encoding="utf-8")) + def test_device_mutation_reports_reauthentication_requirement() -> None: """Mutating Find My commands should surface a clean reauthentication message.""" @@ -2021,6 +2055,33 @@ def test_hidemyemail_commands() -> None: assert "generated@privaterelay.appleid.com" in generate_result.stdout +def test_hidemyemail_update_omits_note_when_not_provided() -> None: + """Label-only updates should not overwrite notes with a synthetic default.""" + + fake_api = FakeAPI() + update_metadata = MagicMock(return_value={"anonymousId": "alias-1", "label": "New"}) + fake_api.hidemyemail.update_metadata = update_metadata + + result = _invoke(fake_api, "hidemyemail", "update", "alias-1", "New") + + assert result.exit_code == 0 + update_metadata.assert_called_once_with("alias-1", "New", None) + + +def test_hidemyemail_mutations_require_valid_payload() -> None: + """Hide My Email mutators should reject empty success payloads.""" + + fake_api = FakeAPI() + fake_api.hidemyemail.delete = MagicMock(return_value={}) + + result = _invoke(fake_api, "hidemyemail", "delete", "alias-1") + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Hide My Email delete returned an invalid response: {}" + ) + + def test_hidemyemail_list_reports_reauthentication_requirement() -> None: """Hide My Email iteration errors should be wrapped in a CLIAbort.""" From 17fe0550f45dfae6190ec3568d8ab48dcb1836e9 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 17:56:57 +0100 Subject: [PATCH 16/18] Handle empty Hide My Email responses --- pyicloud/cli/commands/hidemyemail.py | 14 +++++++++++-- tests/test_cmdline.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index 06339ad2..5243fa15 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -23,6 +23,14 @@ app = typer.Typer(help="Manage Hide My Email aliases.") +def _require_generated_alias(alias: str | None) -> str: + """Return a generated alias or abort on an empty response.""" + + if isinstance(alias, str) and alias: + return alias + raise CLIAbort("Hide My Email generate returned an empty alias.") + + def _require_mutation_result(payload: dict, operation: str) -> str: """Return the alias id from a successful mutator response.""" @@ -109,11 +117,12 @@ def hidemyemail_generate( lambda: api.hidemyemail.generate(), account_name=api.account_name, ) + alias = _require_generated_alias(alias) payload = {"email": alias} if state.json_output: state.write_json(payload) return - state.console.print(alias or "") + state.console.print(alias) @app.command("reserve") @@ -149,10 +158,11 @@ def hidemyemail_reserve( lambda: api.hidemyemail.reserve(email=email, label=label, note=note), account_name=api.account_name, ) + reserved_id = _require_mutation_result(payload, "reserve") if state.json_output: state.write_json(payload) return - state.console.print(payload.get("anonymousId", "reserved")) + state.console.print(reserved_id) @app.command("update") diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index af8ea315..6ec78d16 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -2055,6 +2055,20 @@ def test_hidemyemail_commands() -> None: assert "generated@privaterelay.appleid.com" in generate_result.stdout +def test_hidemyemail_generate_requires_alias() -> None: + """Generate should fail when the backend returns an empty alias.""" + + fake_api = FakeAPI() + fake_api.hidemyemail.generate = MagicMock(return_value=None) + + result = _invoke(fake_api, "hidemyemail", "generate") + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Hide My Email generate returned an empty alias." + ) + + def test_hidemyemail_update_omits_note_when_not_provided() -> None: """Label-only updates should not overwrite notes with a synthetic default.""" @@ -2081,6 +2095,22 @@ def test_hidemyemail_mutations_require_valid_payload() -> None: "Hide My Email delete returned an invalid response: {}" ) + fake_api = FakeAPI() + fake_api.hidemyemail.reserve = MagicMock(return_value={}) + + result = _invoke( + fake_api, + "hidemyemail", + "reserve", + "alias@example.com", + "Shopping", + ) + + assert result.exit_code != 0 + assert result.exception.args[0] == ( + "Hide My Email reserve returned an invalid response: {}" + ) + def test_hidemyemail_list_reports_reauthentication_requirement() -> None: """Hide My Email iteration errors should be wrapped in a CLIAbort.""" From d1a28d00fb20e211f31157202dd12bb015571ca6 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Wed, 18 Mar 2026 18:11:08 +0100 Subject: [PATCH 17/18] Isolate CLI test temp roots --- tests/test_cmdline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 6ec78d16..964a107b 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -22,7 +22,9 @@ output_module = importlib.import_module("pyicloud.cli.output") app = cli_module.app -TEST_ROOT = Path(tempfile.gettempdir()) / "python-test-results" / "test_cmdline" +TEST_BASE = Path(tempfile.gettempdir()) / "python-test-results" +TEST_BASE.mkdir(parents=True, exist_ok=True) +TEST_ROOT = Path(tempfile.mkdtemp(prefix="test_cmdline-", dir=TEST_BASE)) class FakeDevice: From 0eeb54ce087ff63590b3787d144b6e5b21ac0d6b Mon Sep 17 00:00:00 2001 From: Tim Laing <11019084+timlaing@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:09:10 +0000 Subject: [PATCH 18/18] Addressed review comments. --- pyicloud/cli/commands/auth.py | 109 ++++++++++++++++----------- pyicloud/cli/commands/calendar.py | 3 +- pyicloud/cli/commands/contacts.py | 11 ++- pyicloud/cli/commands/devices.py | 32 +++++--- pyicloud/cli/commands/drive.py | 5 +- pyicloud/cli/commands/hidemyemail.py | 22 +++--- pyicloud/cli/context.py | 35 +++++---- pyproject.toml | 5 +- 8 files changed, 135 insertions(+), 87 deletions(-) diff --git a/pyicloud/cli/commands/auth.py b/pyicloud/cli/commands/auth.py index d2442b79..10d7c4cb 100644 --- a/pyicloud/cli/commands/auth.py +++ b/pyicloud/cli/commands/auth.py @@ -4,7 +4,8 @@ import typer -from pyicloud.cli.context import CLIAbort, get_state +from pyicloud.base import PyiCloudService +from pyicloud.cli.context import CLIAbort, CLIState, get_state from pyicloud.cli.options import ( DEFAULT_LOG_LEVEL, DEFAULT_OUTPUT_FORMAT, @@ -26,6 +27,8 @@ app = typer.Typer(help="Manage authentication and sessions.") keyring_app = typer.Typer(help="Manage stored keyring credentials.") +TRUSTED_SESSION = "Trusted Session" + def _group_root(ctx: typer.Context) -> None: """Show subgroup help when invoked without a subcommand.""" @@ -57,7 +60,7 @@ def _auth_status_rows(payload: dict[str, object]) -> list[tuple[str, object]]: return [ ("Account", payload["account_name"]), ("Authenticated", payload["authenticated"]), - ("Trusted Session", payload["trusted_session"]), + (TRUSTED_SESSION, payload["trusted_session"]), ("Requires 2FA", payload["requires_2fa"]), ("Requires 2SA", payload["requires_2sa"]), ("Password in Keyring", payload["has_keyring_password"]), @@ -78,7 +81,7 @@ def _auth_status_rows(payload: dict[str, object]) -> list[tuple[str, object]]: ] -def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: +def _auth_payload(state: CLIState, api, status: dict[str, object]) -> dict[str, object]: payload: dict[str, object] = { "account_name": api.account_name, "has_keyring_password": state.has_keyring_password(api.account_name), @@ -88,38 +91,16 @@ def _auth_payload(state, api, status: dict[str, object]) -> dict[str, object]: return payload -@app.command("status") -def auth_status( - ctx: typer.Context, - username: UsernameOption = None, - session_dir: SessionDirOption = None, - http_proxy: HttpProxyOption = None, - https_proxy: HttpsProxyOption = None, - no_verify_ssl: NoVerifySslOption = False, - output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, - log_level: LogLevelOption = DEFAULT_LOG_LEVEL, -) -> None: - """Show the current authentication and session status.""" - - store_command_options( - ctx, - username=username, - session_dir=session_dir, - http_proxy=http_proxy, - https_proxy=https_proxy, - no_verify_ssl=no_verify_ssl, - output_format=output_format, - log_level=log_level, - ) - state = get_state(ctx) +def _auth_status_authenticated(state: CLIState) -> bool: + """Check if the current state has an authenticated session, and print details if not.""" if not state.has_explicit_username: active_probes = state.active_session_probes() if not active_probes: if state.json_output: state.write_json({"authenticated": False, "accounts": []}) - return + return False state.console.print(state.not_logged_in_message()) - return + return False payloads = [_auth_payload(state, api, status) for api, status in active_probes] if state.json_output: @@ -127,7 +108,7 @@ def auth_status( state.write_json(payloads[0]) else: state.write_json({"authenticated": True, "accounts": payloads}) - return + return False if len(payloads) == 1: payload = payloads[0] state.console.print( @@ -136,13 +117,13 @@ def auth_status( _auth_status_rows(payload), ) ) - return + return False state.console.print( console_table( "Active iCloud Sessions", [ "Account", - "Trusted Session", + TRUSTED_SESSION, "Password in Keyring", "Session File Exists", "Cookie Jar Exists", @@ -159,6 +140,35 @@ def auth_status( ], ) ) + return False + return True + + +@app.command("status") +def auth_status( + ctx: typer.Context, + username: UsernameOption = None, + session_dir: SessionDirOption = None, + http_proxy: HttpProxyOption = None, + https_proxy: HttpsProxyOption = None, + no_verify_ssl: NoVerifySslOption = False, + output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, + log_level: LogLevelOption = DEFAULT_LOG_LEVEL, +) -> None: + """Show the current authentication and session status.""" + + store_command_options( + ctx, + username=username, + session_dir=session_dir, + http_proxy=http_proxy, + https_proxy=https_proxy, + no_verify_ssl=no_verify_ssl, + output_format=output_format, + log_level=log_level, + ) + state = get_state(ctx) + if not _auth_status_authenticated(state): return api = state.get_probe_api() @@ -231,7 +241,7 @@ def auth_login( "Auth Session", [ ("Account", payload["account_name"]), - ("Trusted Session", payload["trusted_session"]), + (TRUSTED_SESSION, payload["trusted_session"]), ("Session File", payload["session_path"]), ("Cookie Jar", payload["cookiejar_path"]), ], @@ -239,6 +249,24 @@ def auth_login( ) +def _auth_logout_find_account(state: CLIState) -> PyiCloudService | None: + active_probes = state.active_session_probes() + if not active_probes: + if state.json_output: + state.write_json({"authenticated": False, "accounts": []}) + return + state.console.print(state.not_logged_in_message()) + return + if len(active_probes) > 1: + raise CLIAbort( + state.multiple_logged_in_accounts_message( + [api.account_name for api, _status in active_probes] + ) + ) + api, _status = active_probes[0] + return api + + @app.command("logout") def auth_logout( ctx: typer.Context, @@ -282,20 +310,9 @@ def auth_logout( api = state.get_probe_api() api.get_auth_status() else: - active_probes = state.active_session_probes() - if not active_probes: - if state.json_output: - state.write_json({"authenticated": False, "accounts": []}) - return - state.console.print(state.not_logged_in_message()) + api = _auth_logout_find_account(state) + if api is None: return - if len(active_probes) > 1: - raise CLIAbort( - state.multiple_logged_in_accounts_message( - [api.account_name for api, _status in active_probes] - ) - ) - api, _status = active_probes[0] state.remember_account(api) try: diff --git a/pyicloud/cli/commands/calendar.py b/pyicloud/cli/commands/calendar.py index dee66910..8dfc3c2d 100644 --- a/pyicloud/cli/commands/calendar.py +++ b/pyicloud/cli/commands/calendar.py @@ -2,7 +2,6 @@ from __future__ import annotations -from itertools import islice from typing import Optional import typer @@ -129,7 +128,7 @@ def calendar_events( payload = [ event for event in payload if event["calendar_guid"] == calendar_guid ] - payload = list(islice(payload, limit)) + payload = payload[:limit] if state.json_output: state.write_json(payload) return diff --git a/pyicloud/cli/commands/contacts.py b/pyicloud/cli/commands/contacts.py index bcd6fec5..766600f9 100644 --- a/pyicloud/cli/commands/contacts.py +++ b/pyicloud/cli/commands/contacts.py @@ -103,10 +103,17 @@ def contacts_me( ) state = get_state(ctx) api = state.get_api() - payload = normalize_me(service_call("Contacts", lambda: api.contacts.me)) + me_data = service_call("Contacts", lambda: api.contacts.me) + if me_data is None: + state.console.print("No contact card found.") + raise typer.Exit(1) + payload = normalize_me(me_data) if state.json_output: state.write_json(payload) return state.console.print(f"{payload['first_name']} {payload['last_name']}") if payload["photo"]: - state.console.print(f"Photo URL: {payload['photo'].get('url')}") + photo = payload["photo"] + url = photo.get("url") if isinstance(photo, dict) else photo + if url: + state.console.print(f"Photo URL: {url}") diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 43bcda0d..3edd36ee 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -30,6 +30,9 @@ app = typer.Typer(help="Work with Find My devices.") +FIND_MY = "Find My" +DEVICE_ID_HELP = "Device id or name." + @app.command("list") def devices_list( @@ -64,7 +67,7 @@ def devices_list( payload = [ normalize_device_summary(device, locate=locate) for device in service_call( - "Find My", + FIND_MY, lambda: api.devices, account_name=api.account_name, ) @@ -94,7 +97,7 @@ def devices_list( @app.command("show") def devices_show( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), locate: bool = typer.Option( False, "--locate", help="Fetch current device location." ), @@ -150,7 +153,7 @@ def devices_show( @app.command("sound") def devices_sound( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), subject: str = typer.Option("Find My iPhone Alert", "--subject"), username: UsernameOption = None, session_dir: SessionDirOption = None, @@ -178,7 +181,7 @@ def devices_sound( api = state.get_api() idevice = resolve_device(api, device) service_call( - "Find My", + FIND_MY, lambda: idevice.play_sound(subject=subject), account_name=api.account_name, ) @@ -192,7 +195,7 @@ def devices_sound( @app.command("message") def devices_message( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), message: str = typer.Argument(..., help="Message to display."), subject: str = typer.Option("A Message", "--subject"), silent: bool = typer.Option(False, "--silent", help="Do not play a sound."), @@ -222,7 +225,7 @@ def devices_message( api = state.get_api() idevice = resolve_device(api, device) service_call( - "Find My", + FIND_MY, lambda: idevice.display_message( subject=subject, message=message, sounds=not silent ), @@ -243,7 +246,7 @@ def devices_message( @app.command("lost-mode") def devices_lost_mode( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), phone: str = typer.Option("", "--phone", help="Phone number shown in lost mode."), message: str = typer.Option( "This iPhone has been lost. Please call me.", @@ -277,7 +280,7 @@ def devices_lost_mode( api = state.get_api() idevice = resolve_device(api, device, require_unique=True) service_call( - "Find My", + FIND_MY, lambda: idevice.lost_device(number=phone, text=message, newpasscode=passcode), account_name=api.account_name, ) @@ -296,11 +299,14 @@ def devices_lost_mode( @app.command("erase") def devices_erase( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), message: str = typer.Option( "This iPhone has been lost. Please call me.", "--message", ), + force: bool = typer.Option( + False, "--force", "-f", help="Skip confirmation prompt." + ), username: UsernameOption = None, session_dir: SessionDirOption = None, http_proxy: HttpProxyOption = None, @@ -326,8 +332,12 @@ def devices_erase( state = get_state(ctx) api = state.get_api() idevice = resolve_device(api, device, require_unique=True) + if not force and not typer.confirm( + f"This will PERMANENTLY ERASE all data on {idevice.name}. Continue?" + ): + raise typer.Abort() service_call( - "Find My", + FIND_MY, lambda: idevice.erase_device(message), account_name=api.account_name, ) @@ -341,7 +351,7 @@ def devices_erase( @app.command("export") def devices_export( ctx: typer.Context, - device: str = typer.Argument(..., help="Device id or name."), + device: str = typer.Argument(..., help=DEVICE_ID_HELP), output: Path = typer.Option(..., "--output", help="Destination JSON file."), raw: bool | None = typer.Option( None, diff --git a/pyicloud/cli/commands/drive.py b/pyicloud/cli/commands/drive.py index 49dba136..f6af5bb1 100644 --- a/pyicloud/cli/commands/drive.py +++ b/pyicloud/cli/commands/drive.py @@ -27,11 +27,14 @@ store_command_options, ) from pyicloud.cli.output import console_table +from pyicloud.services.drive import DriveService app = typer.Typer(help="Browse and download iCloud Drive files.") -def _resolve_drive_node_or_abort(drive, path: str, *, trash: bool = False): +def _resolve_drive_node_or_abort( + drive: DriveService, path: str, *, trash: bool = False +): """Resolve a drive path or raise a user-facing CLI error.""" try: diff --git a/pyicloud/cli/commands/hidemyemail.py b/pyicloud/cli/commands/hidemyemail.py index 5243fa15..71f3f119 100644 --- a/pyicloud/cli/commands/hidemyemail.py +++ b/pyicloud/cli/commands/hidemyemail.py @@ -22,13 +22,15 @@ app = typer.Typer(help="Manage Hide My Email aliases.") +HIDE_MY_EMAIL = "Hide My Email" + def _require_generated_alias(alias: str | None) -> str: """Return a generated alias or abort on an empty response.""" if isinstance(alias, str) and alias: return alias - raise CLIAbort("Hide My Email generate returned an empty alias.") + raise CLIAbort(f"{HIDE_MY_EMAIL} generate returned an empty alias.") def _require_mutation_result(payload: dict, operation: str) -> str: @@ -38,7 +40,7 @@ def _require_mutation_result(payload: dict, operation: str) -> str: if isinstance(anonymous_id, str) and anonymous_id: return anonymous_id raise CLIAbort( - f"Hide My Email {operation} returned an invalid response: {payload!r}" + f"{HIDE_MY_EMAIL} {operation} returned an invalid response: {payload!r}" ) @@ -68,7 +70,7 @@ def hidemyemail_list( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: [normalize_alias(alias) for alias in api.hidemyemail], account_name=api.account_name, ) @@ -77,7 +79,7 @@ def hidemyemail_list( return state.console.print( console_table( - "Hide My Email", + HIDE_MY_EMAIL, ["Alias", "Label", "Anonymous ID"], [ (alias["email"], alias["label"], alias["anonymous_id"]) @@ -113,7 +115,7 @@ def hidemyemail_generate( state = get_state(ctx) api = state.get_api() alias = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.generate(), account_name=api.account_name, ) @@ -154,7 +156,7 @@ def hidemyemail_reserve( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.reserve(email=email, label=label, note=note), account_name=api.account_name, ) @@ -194,7 +196,7 @@ def hidemyemail_update( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.update_metadata(anonymous_id, label, note), account_name=api.account_name, ) @@ -232,7 +234,7 @@ def hidemyemail_deactivate( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.deactivate(anonymous_id), account_name=api.account_name, ) @@ -270,7 +272,7 @@ def hidemyemail_reactivate( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.reactivate(anonymous_id), account_name=api.account_name, ) @@ -308,7 +310,7 @@ def hidemyemail_delete( state = get_state(ctx) api = state.get_api() payload = service_call( - "Hide My Email", + HIDE_MY_EMAIL, lambda: api.hidemyemail.delete(anonymous_id), account_name=api.account_name, ) diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index b4c17ac6..ab22fccb 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -657,21 +657,30 @@ def resolve_drive_node(drive, path: str, *, trash: bool = False): return node +def _write_to_file(response: Any, file_out) -> None: + """Write a download response to a file, streaming if possible.""" + if hasattr(response, "iter_content"): + for chunk in response.iter_content(chunk_size=8192): + if chunk: + file_out.write(chunk) + return + if hasattr(response, "raw") and hasattr(response.raw, "read"): + while True: + chunk = response.raw.read(8192) + if not chunk: + break + file_out.write(chunk) + + def write_response_to_path(response: Any, output: Path) -> None: """Stream a download response to disk.""" + can_stream = hasattr(response, "iter_content") or ( + hasattr(response, "raw") and hasattr(response.raw, "read") + ) + if not can_stream: + raise CLIAbort("The download response could not be streamed.") + output.parent.mkdir(parents=True, exist_ok=True) with output.open("wb") as file_out: - if hasattr(response, "iter_content"): - for chunk in response.iter_content(chunk_size=8192): - if chunk: - file_out.write(chunk) - return - if hasattr(response, "raw") and hasattr(response.raw, "read"): - while True: - chunk = response.raw.read(8192) - if not chunk: - break - file_out.write(chunk) - return - raise CLIAbort("The download response could not be streamed.") + _write_to_file(response, file_out) diff --git a/pyproject.toml b/pyproject.toml index 0f123194..42383d27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,8 +103,9 @@ repository = "https://github.com/timlaing/pyicloud" [project.scripts] icloud = "pyicloud.cmdline:main" -[tool.setuptools] -packages = ["pyicloud", "pyicloud.cli", "pyicloud.cli.commands", "pyicloud.services"] +[tool.setuptools.packages.find] +where = ["."] +include = ["pyicloud*"] [tool.setuptools.dynamic] readme = {file = "README.md", content-type = "text/markdown"}