diff --git a/README.md b/README.md index 2007765c..67f41f07 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,52 +57,124 @@ 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 ``` -You can also store your password in the system keyring using the +## Command-Line Interface + +The `icloud` command line interface is organized around top-level +subcommands such as `auth`, `account`, `devices`, `calendar`, +`contacts`, `drive`, `photos`, and `hidemyemail`. + +Command options belong on the final command that uses them. For example: + +```console +$ icloud auth login --username jappleseed@apple.com +$ icloud account summary --format 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 +$ icloud auth login --username jappleseed@apple.com Enter iCloud password for jappleseed@apple.com: 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') ``` -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: +CLI examples: ```console -$ icloud --username=jappleseed@apple.com --delete-from-keyring -Enter iCloud password for jappleseed@apple.com: -Save password in keyring? [y/N]: N +$ icloud auth status +$ 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 account summary +$ icloud account summary --format json +$ icloud devices list --locate +$ icloud devices list --with-family +$ 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 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 ``` -**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. +If you would like to delete a password stored in your system keyring, +use the dedicated keyring subcommand: + +```console +$ icloud auth keyring delete --username jappleseed@apple.com +``` + +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 keyring delete --username `: delete the stored password without logging out +- `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 auth login --username `. 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 auth keyring delete --username ` if you also want +to forget the saved password. + +**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) @@ -101,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 @@ -151,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 @@ -486,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'}] ``` diff --git a/pyicloud/base.py b/pyicloud/base.py index 8f8b8cce..51f86efe 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: @@ -141,12 +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() @@ -156,7 +167,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 +218,14 @@ def __init__( self._requires_mfa: bool = False - self.authenticate() + 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 @@ -298,6 +316,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) @@ -669,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/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/account_index.py b/pyicloud/cli/account_index.py new file mode 100644 index 00000000..05a92532 --- /dev/null +++ b/pyicloud/cli/account_index.py @@ -0,0 +1,205 @@ +"""Local account discovery index for the Typer CLI.""" + +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, Iterator, TypedDict + +ACCOUNT_INDEX_FILENAME = "accounts.json" + + +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: + """Return the JSON file path for the local account index.""" + + return Path(session_root) / ACCOUNT_INDEX_FILENAME + + +def _load_accounts_from_path(index_path: Path) -> dict[str, AccountIndexEntry]: + """Load indexed accounts from a specific path.""" + + 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, + } + china_mainland = entry.get("china_mainland") + if isinstance(china_mainland, bool): + normalized[username]["china_mainland"] = china_mainland + 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) + try: + lock_path.unlink() + except FileNotFoundError: + pass + except OSError: + pass + + +def _save_accounts( + session_root: str | Path, accounts: dict[str, AccountIndexEntry] +) -> None: + """Persist indexed accounts to disk.""" + + _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() + except FileNotFoundError: + pass + return + + index_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "accounts": {username: accounts[username] for username in sorted(accounts)} + } + 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( + 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.""" + + 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)] + + +def remember_account( + session_root: str | Path, + *, + 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.""" + + 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 new file mode 100644 index 00000000..9bb5ae39 --- /dev/null +++ b/pyicloud/cli/app.py @@ -0,0 +1,67 @@ +"""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.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 +from pyicloud.cli.commands.drive import app as drive_app +from pyicloud.cli.commands.hidemyemail import app as hidemyemail_app +from pyicloud.cli.commands.photos import app as photos_app +from pyicloud.cli.context import CLIAbort + +app = typer.Typer( + help="Command line interface for pyicloud services.", + no_args_is_help=True, + pretty_exceptions_show_locals=False, +) + + +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, +) + + +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..74ded1ae --- /dev/null +++ b/pyicloud/cli/commands/account.py @@ -0,0 +1,224 @@ +"""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.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") +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( + "Account", lambda: api.account, account_name=api.account_name + ) + 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, + 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 = [ + normalize_account_device(device) + for device in service_call( + "Account", + lambda: api.account.devices, + account_name=api.account_name, + ) + ] + 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, + 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 = [ + normalize_family_member(member) + for member in service_call( + "Account", + lambda: api.account.family, + account_name=api.account_name, + ) + ] + 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, + 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( + service_call( + "Account", lambda: api.account.storage, account_name=api.account_name + ) + ) + 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/auth.py b/pyicloud/cli/commands/auth.py new file mode 100644 index 00000000..10d7c4cb --- /dev/null +++ b/pyicloud/cli/commands/auth.py @@ -0,0 +1,389 @@ +"""Authentication and session commands.""" + +from __future__ import annotations + +import typer + +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, + AcceptTermsOption, + ChinaMainlandOption, + HttpProxyOption, + HttpsProxyOption, + InteractiveOption, + LogLevelOption, + NoVerifySslOption, + OutputFormatOption, + PasswordOption, + SessionDirOption, + UsernameOption, + store_command_options, +) +from pyicloud.cli.output import console_kv_table, console_table + +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.""" + + 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, +) + + +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: 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), + **state.auth_storage_info(api), + **status, + } + return payload + + +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 False + state.console.print(state.not_logged_in_message()) + return False + + 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 False + if len(payloads) == 1: + payload = payloads[0] + state.console.print( + console_kv_table( + "Auth Status", + _auth_status_rows(payload), + ) + ) + return False + state.console.print( + console_table( + "Active iCloud Sessions", + [ + "Account", + TRUSTED_SESSION, + "Password in Keyring", + "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 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() + 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", + _auth_status_rows(payload), + ) + ) + + +@app.command("login") +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( + 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"]), + ], + ) + ) + + +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, + 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.", + ), + 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() + api.get_auth_status() + else: + api = _auth_logout_find_account(state) + if api is None: + return + + 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.") + + +@keyring_app.command("delete") +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.") + + 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 new file mode 100644 index 00000000..8dfc3c2d --- /dev/null +++ b/pyicloud/cli/commands/calendar.py @@ -0,0 +1,150 @@ +"""Calendar commands.""" + +from __future__ import annotations + +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.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") +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 = [ + 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. Filtering is applied client-side.", + ), + limit: int = typer.Option( + 50, + "--limit", + min=1, + help="Maximum events to show after filtering.", + ), + 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 = [ + 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 = 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..766600f9 --- /dev/null +++ b/pyicloud/cli/commands/contacts.py @@ -0,0 +1,119 @@ +"""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.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") +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 = [ + 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, + 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() + 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"]: + 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 new file mode 100644 index 00000000..3edd36ee --- /dev/null +++ b/pyicloud/cli/commands/devices.py @@ -0,0 +1,403 @@ +"""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.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, + print_json_text, + write_json_file, +) + +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( + 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 = [ + normalize_device_summary(device, locate=locate) + for device in service_call( + FIND_MY, + lambda: api.devices, + account_name=api.account_name, + ) + ] + 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_HELP), + locate: bool = typer.Option( + 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) + 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_HELP), + 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) + 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) + 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_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."), + 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) + 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, + "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_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.", + "--message", + 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, 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, + "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_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, + 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, 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, + 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) + 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_HELP), + output: Path = typer.Option(..., "--output", help="Destination JSON file."), + raw: bool | None = typer.Option( + None, + "--raw/--no-raw", + help="Write the raw device payload.", + ), + normalized: bool = typer.Option( + False, + "--normalized", + 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) + if raw and normalized: + raise typer.BadParameter("Choose either --raw or --normalized, not both.") + + 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}) + 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..f6af5bb1 --- /dev/null +++ b/pyicloud/cli/commands/drive.py @@ -0,0 +1,153 @@ +"""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.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 +from pyicloud.services.drive import DriveService + +app = typer.Typer(help="Browse and download iCloud Drive files.") + + +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: + return resolve_drive_node(drive, path, trash=trash) + except KeyError as err: + raise CLIAbort(f"Path not found: {path}") from err + + +@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." + ), + 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, 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: + 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." + ), + 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, 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) + 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..71f3f119 --- /dev/null +++ b/pyicloud/cli/commands/hidemyemail.py @@ -0,0 +1,321 @@ +"""Hide My Email commands.""" + +from __future__ import annotations + +import typer + +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, + 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.") + +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(f"{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.""" + + 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, + 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 = 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 + 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, + 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(), + 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) + + +@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."), + 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( + HIDE_MY_EMAIL, + 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(reserved_id) + + +@app.command("update") +def hidemyemail_update( + ctx: typer.Context, + anonymous_id: str = typer.Argument(...), + label: str = typer.Argument(...), + note: str | None = typer.Option(None, "--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( + HIDE_MY_EMAIL, + 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 {updated_id}") + + +@app.command("deactivate") +def hidemyemail_deactivate( + 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( + HIDE_MY_EMAIL, + 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 {deactivated_id}") + + +@app.command("reactivate") +def hidemyemail_reactivate( + 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( + HIDE_MY_EMAIL, + 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 {reactivated_id}") + + +@app.command("delete") +def hidemyemail_delete( + 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( + HIDE_MY_EMAIL, + 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 {deleted_id}") diff --git a/pyicloud/cli/commands/photos.py b/pyicloud/cli/commands/photos.py new file mode 100644 index 00000000..2ba88137 --- /dev/null +++ b/pyicloud/cli/commands/photos.py @@ -0,0 +1,200 @@ +"""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.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") +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, 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 + 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."), + 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, 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.") + 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 + 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." + ), + 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, 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) + 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/context.py b/pyicloud/cli/context.py new file mode 100644 index 00000000..ab22fccb --- /dev/null +++ b/pyicloud/cli/context.py @@ -0,0 +1,686 @@ +"""Shared context and authentication helpers for the Typer CLI.""" + +from __future__ import annotations + +import logging +from contextlib import ExitStack +from dataclasses import dataclass +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.base import resolve_cookie_directory +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 .output import OutputFormat, write_json + +COMMAND_OPTIONS_META_KEY = "command_options" + + +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 + + +@dataclass(frozen=True) +class CLICommandOptions: + """Command-local options captured from the leaf command.""" + + username: Optional[str] = None + password: Optional[str] = None + china_mainland: 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: bool = False + log_level: LogLevel = LogLevel.WARNING + output_format: OutputFormat = OutputFormat.TEXT + + +class CLIState: + """Shared CLI state and authenticated API access.""" + + def __init__( + self, + *, + username: Optional[str], + password: Optional[str], + china_mainland: Optional[bool], + interactive: 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 or "").strip() + self.password = password + self.china_mainland = china_mainland + self.interactive = interactive + 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 + 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": + """Build CLI state from one leaf command's options.""" + + return cls( + 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 + def has_explicit_username(self) -> bool: + """Return whether the user explicitly passed --username.""" + + return bool(self.username) + + @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_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 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 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.""" + + remember_account( + self.session_root, + 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: + 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 auth login --username " + ) + + 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 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) -> 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_from_keyring(username) + + 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 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.") + + def get_login_api(self) -> PyiCloudService: + """Return a PyiCloudService, bootstrapping login if needed.""" + + if self._api is not None: + return self._api + username = self._resolve_username() + + password, password_source = self._password_for_login(username) + if not password: + raise CLIAbort("No password supplied and no stored password was found.") + + self._configure_logging() + + try: + api = PyiCloudService( + apple_id=username, + password=password, + china_mainland=self.resolved_china_mainland(username), + cookie_directory=self.session_dir, + accept_terms=self.accept_terms, + with_family=self.with_family, + ) + except PyiCloudFailedLoginException as err: + 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 + + if ( + not utils.password_exists_in_keyring(username) + and self.interactive + and confirm("Save password in keyring?") + ): + utils.store_password_in_keyring(username, password) + + if api.requires_2fa: + self._handle_2fa(api) + elif api.requires_2sa: + 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() + 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 + + active_probes = self.active_session_probes() + if not active_probes: + raise CLIAbort(self.not_logged_in_message()) + if len(active_probes) > 1: + raise CLIAbort( + self.multiple_logged_in_accounts_message( + [api.account_name for api, _status in active_probes] + ) + ) + + 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 + + def build_probe_api(self, username: str) -> PyiCloudService: + """Build a non-authenticating PyiCloudService for session probes.""" + + self._configure_logging() + return PyiCloudService( + apple_id=username, + password=self.password, + china_mainland=self.resolved_china_mainland(username), + cookie_directory=self.session_dir, + accept_terms=self.accept_terms, + with_family=self.with_family, + 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.""" + + 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 resolved CLI state for a leaf command.""" + + root_ctx = ctx.find_root() + state = root_ctx.obj + if isinstance(state, CLIState): + return state + + 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 + ctx.obj = resolved + return resolved + + +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]: + """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, *, require_unique: bool = False): + """Return a device matched by id or common display names.""" + + lowered = query.strip().lower() + 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, "name", ""), + getattr(device, "deviceDisplayName", ""), + ] + 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): + """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_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: + _write_to_file(response, file_out) diff --git a/pyicloud/cli/normalize.py b/pyicloud/cli/normalize.py new file mode 100644 index 00000000..acc65557 --- /dev/null +++ b/pyicloud/cli/normalize.py @@ -0,0 +1,176 @@ +"""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"), + } diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py new file mode 100644 index 00000000..de06d5f7 --- /dev/null +++ b/pyicloud/cli/options.py @@ -0,0 +1,168 @@ +"""Shared Typer option aliases and helpers for CLI leaf commands.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from .context import COMMAND_OPTIONS_META_KEY, LogLevel +from .output import OutputFormat + +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." +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." +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." + +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/cli/output.py b/pyicloud/cli/output.py new file mode 100644 index 00000000..9d816da4 --- /dev/null +++ b/pyicloud/cli/output.py @@ -0,0 +1,121 @@ +"""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 + +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.""" + + 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, + 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: + 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, + 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)) + 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)) 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/pyicloud/session.py b/pyicloud/session.py index b3e34e7b..b696bfd9 100644 --- a/pyicloud/session.py +++ b/pyicloud/session.py @@ -111,6 +111,30 @@ 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) 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 = {} + + 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(): @@ -265,8 +289,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/pyproject.toml b/pyproject.toml index 6033255d..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.services"] +[tool.setuptools.packages.find] +where = ["."] +include = ["pyicloud*"] [tool.setuptools.dynamic] readme = {file = "README.md", content-type = "text/markdown"} diff --git a/requirements.txt b/requirements.txt index 9dc0bd8c..bca6c954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +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/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_base.py b/tests/test_base.py index 0d393c81..92f95d79 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 @@ -11,6 +13,7 @@ from requests import HTTPError, Response from pyicloud import PyiCloudService +from pyicloud.cookie_jar import PyiCloudCookieJar from pyicloud.exceptions import ( PyiCloud2SARequiredException, PyiCloudAcceptTermsException, @@ -57,6 +60,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 +138,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 +346,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 +423,42 @@ 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_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}} @@ -470,7 +697,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() diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index f5c7064b..964a107b 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -1,423 +1,2148 @@ -"""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 +"""Tests for the Typer-based pyicloud CLI.""" + +from __future__ import annotations + +import importlib +import json +import tempfile +from contextlib import nullcontext +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Optional +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") +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_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: + """Find My device fixture.""" + + def __init__(self) -> None: + self.id = "device-1" + self.name = "Example 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 + + @property + def photos(self): + return iter(self._photos) + + 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: Optional[str] + ) -> dict[str, Any]: + 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} + + 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} + + +class FakeAPI: + """Authenticated API fixture.""" + + def __init__( + self, + *, + 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) + 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 = 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.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( + devices=[ + { + "name": "Example 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"}]}, + ), + ) + 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() + + 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 _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) + return path + + +def _remember_local_account( + session_dir: Path, + username: str, + *, + 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, + china_mainland=bool(china_mainland), + ) + 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, + china_mainland=china_mainland, + 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] = 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 = 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("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"), + 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()), + ), + patch.object( + context_module.utils, + "get_password_from_keyring", + side_effect=lambda candidate: ( + "stored-secret" if candidate in (keyring_passwords or set()) else None + ), + ), ): - 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() + 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()), + ), + 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) + + +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 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", + "devices", + "calendar", + "contacts", + "drive", + "photos", + "hidemyemail", + ): + assert command in text + + +def test_group_help() -> None: + """Each command group should expose help.""" + + for command in ( + "account", + "auth", + "devices", + "calendar", + "contacts", + "drive", + "photos", + "hidemyemail", + ): + result = _runner().invoke(app, [command, "--help"]) + 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", + ): + result = _runner().invoke(app, [command]) + text = _plain_output(result) + assert result.exit_code == 0 + 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 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 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 text + + +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_format_option_outputs_json() -> None: + """Leaf --format should support machine-readable JSON.""" + + 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" + 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), + [ + "account", + "summary", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + "--format", + "json", + ], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 0 + assert payload["account_name"] == "user@example.com" + + +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 _plain_output(result) + + +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 - # 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"), + 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 + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): - 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, + result = _runner().invoke( + app, + [ + "auth", + "login", + "--username", + "leaf@example.com", + "--password", + "secret", + "--session-dir", + str(session_dir), + "--non-interactive", + ], ) - main() - # We should not use getpass for this one, but we reset the password at login fail + 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("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"), + 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 + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): - 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, + result = _runner().invoke( + app, + [ + "account", + "summary", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + "--format", + "text", + ], ) - main() + assert result.exit_code == 0 + assert "Account: user@example.com" in result.stdout + + +def test_china_mainland_is_login_only() -> None: + """China mainland selection should only be accepted on auth login.""" + + 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" in status_text + assert "--china-mainland" in status_text + assert service_result.exit_code != 0 + assert "No such option" in service_text + assert "--china-mainland" in service_text + + +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") -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), + 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 + ), ): - 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, + login_result = _runner().invoke( + app, + [ + "auth", + "login", + "--username", + "cn@example.com", + "--password", + "secret", + "--session-dir", + str(session_dir), + "--china-mainland", + "--non-interactive", + ], ) - main() + 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.""" -def test_device_outputfile(mock_file_open_write_fixture: MagicMock) -> None: - """Test the outputfile command.""" + 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("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), + 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 + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value=None + ), ): - 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, + result = _runner().invoke( + app, + [ + "account", + "summary", + "--username", + "cn@example.com", + "--session-dir", + str(session_dir), + ], ) - 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" + + assert result.exit_code == 0 + assert "Account: cn@example.com" in result.stdout + + +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_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", "--session-dir", str(session_dir)] + ) + assert result.exit_code != 0 + assert ( + result.exception.args[0] + == "You are not logged into any iCloud accounts. To log in, run: " + "icloud auth login --username " + ) + + +def test_auth_keyring_delete() -> None: + """The keyring delete subcommand should delete stored credentials.""" + + 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, "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, + [ + "auth", + "keyring", + "delete", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + ], + ) + 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_keyring_delete_requires_explicit_username() -> None: + """Deleting stored credentials should require an explicit username.""" + + result = _runner().invoke( + app, + ["auth", "keyring", "delete"], + ) + + assert result.exit_code != 0 + assert ( + result.exception.args[0] + == "The --username option is required for auth keyring delete." + ) + + +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("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.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), ): - 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 + result = _runner().invoke( + app, + ["auth", "status", "--session-dir", str(session_dir)], ) + 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.""" -def test_create_parser() -> None: - """Test the creation of the parser.""" - parser: argparse.ArgumentParser = _create_parser() - assert isinstance(parser, argparse.ArgumentParser) + 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_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", +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"}, ) - dev = MagicMock() - _enable_lost_mode_option(command_line, dev) - dev.lost_device.assert_called_with( - number="1234567890", text="Lost", newpasscode="pass" + 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.""" + + fake_api = FakeAPI() + 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) + + 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 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 auth login --username solo@example.com" + ) + 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_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 -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 + 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.""" -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 + 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 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 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, + ) -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"}], + 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", ), ): - _handle_2sa(api) + 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.""" - api.send_verification_code.assert_called_once_with( - {"deviceName": "Test Device"} + 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, + [ + "auth", + "login", + "--session-dir", + str(session_dir), + "--non-interactive", + ], ) - api.validate_verification_code.assert_called_once_with( - {"deviceName": "Test Device"}, - "123456", + + 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, + [ + "account", + "summary", + "--session-dir", + str(session_dir), + ], ) + 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_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_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"}, + ) -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, + 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"}, ) - # Create a mock device object + assert result.exit_code == 0 + assert "beta@example.com" in result.stdout - dev = MagicMock() - location = PropertyMock(return_value="Test Location") - type(dev).location = location - # Call the function - _list_devices_option(command_line, dev) +def test_authenticated_commands_update_account_index() -> None: + """Successful authenticated commands should index the resolved account.""" - # Verify that the location() method was called - location.assert_called_once() + 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) -def test_list_devices_option() -> None: - """Test the list devices option.""" - command_line = MagicMock( - longlist=True, - locate=False, - output_to_file=False, - list=False, + 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 ) - content: dict[str, str] = { - "name": "Test Device", - "deviceDisplayName": "Test Display", - "location": "Test Location", - "batteryLevel": "100%", - "batteryStatus": "Charging", - "deviceClass": "Phone", - "deviceModel": "iPhone", + + +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_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"), + } } - dev = AppleDevice( - content=content, - params={}, - manager=MagicMock(), - sound_url="", - lost_url="", - message_url="", - erase_token_url="", - erase_url="", - ) - - 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 - ) - - # 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}, - ], + + 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.""" + + 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_from_keyring", return_value=None + ), + ): + result = _runner().invoke( + app, + [ + "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( + result.exception + ) + + +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.""" + + 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), + "auth", + "logout", + *args, + username=None, + session_dir=session_dir, + output_format="json", + 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), + "auth", + "logout", + "--remove-keyring", + username=None, + session_dir=session_dir, + output_format="json", + 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") + + +def test_security_key_flow() -> None: + """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, "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: + """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, "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( + fake_api.trusted_devices[0], "123456" + ) + + +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.""" + + 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, + "devices", + "show", + "device-1", + "--raw", + output_format="json", + ) + assert list_result.exit_code == 0 + 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 + 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.""" + + fake_api = FakeAPI() + export_path = TEST_ROOT / "device.json" + export_path.parent.mkdir(parents=True, exist_ok=True) + sound_result = _invoke( + fake_api, + "devices", + "sound", + "device-1", + "--subject", + "Ping", + output_format="json", + ) + silent_result = _invoke( + fake_api, + "devices", + "message", + "device-1", + "Hello", + "--silent", + ) + lost_result = _invoke( + fake_api, + "devices", + "lost-mode", + "device-1", + "--phone", + "123", + "--message", + "Lost", + "--passcode", + "4567", + ) + export_result = _invoke( + fake_api, + "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" + 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="", - ) - - 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") + assert export_result.exit_code == 0 + 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 + + 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.""" + + 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.""" + + 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 = 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( + fake_api, + "photos", + "download", + "photo-1", + "--output", + str(output_path), + ) + json_drive_result = _invoke( + fake_api, + "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 + 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_drive_missing_paths_report_cli_abort() -> None: + """Drive commands should collapse missing path lookups into CLIAbort errors.""" + + fake_api = FakeAPI() + output_path = TEST_ROOT / "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 = TEST_ROOT / "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.""" + + 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_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.""" + + 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: {}" + ) + + 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.""" + + 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.""" + + 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() + assert code == 1 + assert captured.out == "" + assert message in captured.err 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