Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 108 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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 <apple-id>`: 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 <apple-id>`. 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 <apple-id>` 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)

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'}]
```

Expand Down
134 changes: 123 additions & 11 deletions pyicloud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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()

Expand All @@ -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] = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading