././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6185791 pyicloud-2.0.1/0000755000175100001660000000000015023360710012742 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6065793 pyicloud-2.0.1/.devcontainer/0000755000175100001660000000000015023360710015501 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.devcontainer/Dockerfile0000644000175100001660000000034215023360704017475 0ustar00runnerdockerFROM mcr.microsoft.com/devcontainers/python:1-3.13-bookworm RUN apt update \ && apt install -y openjdk-17-jre-headless \ && pip install pre-commit==3.5.0 \ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.devcontainer/devcontainer.json0000644000175100001660000000431015023360704021056 0ustar00runnerdocker// For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/python { "name": "PyiCloud Dev", "build": { "dockerfile": "Dockerfile" }, // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/github-cli:1": { "version": "latest" }, "ghcr.io/devcontainers/features/java:1": { "version": "latest", "jdkDistro": "open", "gradleVersion": "latest", "mavenVersion": "latest", "antVersion": "latest", "groovyVersion": "latest" }, "ghcr.io/devcontainers/features/node:1": { "version": "lts", "pnpmVersion": "latest", "nvmVersion": "latest" }, "ghcr.io/va-h/devcontainers-features/uv:1": { "shellautocompletion": true, "version": "latest" } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "scripts/setup.sh", // Configure tool-specific properties. "customizations": { "vscode": { "extensions": [ "donjayamanne.python-extension-pack", "shyykoserhiy.git-autoconfig", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", "esbenp.prettier-vscode", "ms-python.pylint", "ms-python.isort", "ms-python.python", "ryanluker.vscode-coverage-gutters", "donjayamanne.git-extension-pack", "github.vscode-github-actions", "elagil.pre-commit-helper", "sonarsource.sonarlint-vscode", "charliermarsh.ruff", "ms-azuretools.vscode-docker" ], "settings": { "sonarlint.ls.javaHome": "/usr/local/sdkman/candidates/java/current", "editor.tabSize": 4, "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh", "args": [ "-l" ] } }, "terminal.integrated.defaultProfile.linux": "zsh" } } }, "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6065793 pyicloud-2.0.1/.github/0000755000175100001660000000000015023360710014302 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/FUNDING.yml0000644000175100001660000000152615023360704016126 0ustar00runnerdocker# These are supported funding model platforms github: [timlaing] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6075792 pyicloud-2.0.1/.github/ISSUE_TEMPLATE/0000755000175100001660000000000015023360710016465 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/ISSUE_TEMPLATE/BUG.md0000644000175100001660000000211315023360704017424 0ustar00runnerdocker--- name: Report a bug with pyiCloud about: Report an issue --- ## The problem ## Environment - pyiCloud release with the issue (`pip show pyicloud`): - Last working pyiCloud release (if known): - Service causing this issue: - Python version (`python -V`): - Operating environment (project deps/Docker/Windows/etc.): ## Traceback/Error logs ```shell ``` ## Additional information ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md0000644000175100001660000000163715023360704021324 0ustar00runnerdocker--- name: Request a feature to pyiCloud about: Request a feature --- ## The request ## Environment - pyiCloud version (`pip show pyicloud`): - Python version (`python -V`): - Operating environment (project deps/Docker/Windows/etc.): ## Checklist - [ ] I've looked informations into the README. - [ ] I've looked informations into the pyiCloud's code. ## Additional information ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/ISSUE_TEMPLATE/SUPPORT.md0000644000175100001660000000254515023360704020174 0ustar00runnerdocker--- name: Need help with pyiCloud about: Need help --- ## The problem ## Environment - pyiCloud release with the issue (`pip show pyicloud`): - Last working pyiCloud release (if known): - Service causing this issue: - Python version (`python -V`): - Operating environment (project deps/Docker/Windows/etc.): ## Traceback/Error logs ```shell ``` ## Checklist - [ ] I've looked informations into the README. - [ ] I've looked informations into the pyiCloud's code. - [ ] I've looked informations in Google. ## Additional information ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/PULL_REQUEST_TEMPLATE.md0000644000175100001660000000506215023360704020111 0ustar00runnerdocker ## Breaking change ## Proposed change ## Type of change - [ ] Dependency upgrade - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New service (thank you!) - [ ] New feature (which adds functionality to an existing service) - [ ] Breaking change (fix/feature causing existing functionality to break) - [ ] Code quality improvements to existing code or addition of tests - [ ] Documentation or code sample ## Example of code: ```python ``` ## Additional information - This PR fixes or closes issue: fixes # - This PR is related to issue: ## Checklist - [ ] The code change is tested and works locally. - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. - [ ] Tests have been added to verify that the new code works. If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated to README ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/dependabot.yml0000644000175100001660000000121415023360704017133 0ustar00runnerdocker# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for more information: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://containers.dev/guide/dependabot version: 2 updates: - package-ecosystem: "devcontainers" directory: "/" schedule: interval: weekly - package-ecosystem: "pip" directory: "/" schedule: interval: weekly - package-ecosystem: "github-actions" directory: "/" schedule: interval: weekly ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/release-drafter.yml0000644000175100001660000000005415023360704020074 0ustar00runnerdockertemplate: | ## What's Changed $CHANGES ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6085792 pyicloud-2.0.1/.github/workflows/0000755000175100001660000000000015023360710016337 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/workflows/linting.yml0000644000175100001660000000105315023360704020530 0ustar00runnerdockername: Linting on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements_test.txt - name: isort run: isort --recursive --diff . - name: Ruff Linter run: ruff check - name: Ruff Formatter run: ruff format --check ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/workflows/publish.yml0000644000175100001660000000376715023360704020550 0ustar00runnerdockername: Build and Publish Python Package # This workflow builds a Python package and publishes it to PyPI or TestPyPI on: release: types: [published, prereleased] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install build dependencies run: python -m pip install --upgrade pip build - name: Build package run: python -m build - name: Upload dist artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/* pypi-publish: name: Upload release to PyPI if: ${{ !github.event.release.prerelease }} needs: build runs-on: ubuntu-latest environment: pypi permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v4 - name: Download dist artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist verbose: true print-hash: true test-pypi-publish: name: Upload release to TestPyPI if: ${{ github.event.release.prerelease }} needs: build runs-on: ubuntu-latest environment: testpypi permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v4 - name: Download dist artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist verbose: true print-hash: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/workflows/release-drafter.yml0000644000175100001660000000154115023360704022133 0ustar00runnerdockername: Release Drafter on: push: # branches to consider in the event; optional, defaults to all branches: - main # pull_request event is required only for autolabeler pull_request: # Only following types are handled by the action, but one can default to all as well types: [opened, reopened, synchronize] permissions: contents: read jobs: update_release_draft: permissions: # write permission is required to create a github release contents: write # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: write runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/workflows/sonarcube.yml0000644000175100001660000000425215023360704021051 0ustar00runnerdockername: SonarCloud.io permissions: contents: read pull-requests: write on: workflow_run: workflows: - Tests types: - completed jobs: check_pr: if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest outputs: pr_number: ${{ steps.get_pr.outputs.pr_number }} head_ref: ${{ steps.get_pr.outputs.head_ref }} base_ref: ${{ steps.get_pr.outputs.base_ref }} steps: - name: Get PR number id: get_pr env: PULL_REQUESTS_JSON: ${{ toJson(github.event.workflow_run.pull_requests) }} run: | echo "pr_number=$(jq -r '.pull_requests[0].number' <<< \"$PULL_REQUESTS_JSON\")" >> $GITHUB_OUTPUT echo "head_ref=$(jq -r '.pull_requests[0].head.ref' <<< \"$PULL_REQUESTS_JSON\")" >> $GITHUB_OUTPUT echo "base_ref=$(jq -r '.pull_requests[0].base.ref' <<< \"$PULL_REQUESTS_JSON\")" >> $GITHUB_OUTPUT sonar: needs: check_pr if: ${{ needs.check_pr.outputs.pr_number != '' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: repository: ${{ github.event.workflow_run.head_repository.full_name }} ref: ${{ github.event.workflow_run.head_branch }} fetch-depth: 0 - uses: actions/download-artifact@v4 with: name: code-coverage github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} repository: ${{ github.event.workflow_run.head_repository.full_name }} - name: Display structure of downloaded files run: ls -R - uses: SonarSource/sonarqube-scan-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > -Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }} -Dsonar.pullrequest.key=${{ needs.check_pr.outputs.pr_number }} -Dsonar.pullrequest.branch=${{ needs.check_pr.outputs.head_ref }} -Dsonar.pullrequest.base=${{ needs.check_pr.outputs.base_ref }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.github/workflows/tests.yml0000644000175100001660000000301015023360704020221 0ustar00runnerdockername: Tests on: [workflow_dispatch, push, pull_request] permissions: contents: read pull-requests: write jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Test with tox run: tox coverage: name: Generate Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements_all.txt - name: Get coverage info run: pytest --cov=. --cov-config=.coveragerc --cov-report xml:coverage.xml - name: Upload code coverage uses: actions/upload-artifact@v4 with: name: code-coverage path: coverage.xml ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.gitignore0000644000175100001660000000144515023360704014741 0ustar00runnerdocker# Python *.py[cod] # C extensions *.so # Packages *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 pip-wheel-metadata # Logs *.log pip-log.txt # Unit test / coverage reports .coverage .tox coverage.xml nosetests.xml htmlcov/ test-reports/ test-results.xml test-output.xml test.py # Translations *.mo # Mac OS X .DS_Store .AppleDouble .LSOverride Icon # Windows Explorer desktop.ini # Visual Studio Code .vscode/* !.vscode/cSpell.json !.vscode/extensions.json !.vscode/tasks.json !.vscode/settings.json !.vscode/launch.json # IntelliJ IDEA .idea *.iml # Sublime text *.sublime-project *.sublime-workspace # Mr Developer .mr.developer.cfg .project .pydevproject .pyvenv .venv *.session *.cookiejar .ruff_cache .pytest_cache .python-version uv.lock ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.pre-commit-config.yaml0000644000175100001660000000140415023360704017225 0ustar00runnerdocker# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: detect-private-key - id: check-docstring-first - id: end-of-file-fixer - id: check-yaml args: - --unsafe - id: check-added-large-files - id: requirements-txt-fixer - id: name-tests-test exclude: ^tests/const.*\.py$ args: - --pytest-test-first - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.6 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pycqa/isort rev: 6.0.1 hooks: - id: isort ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6085792 pyicloud-2.0.1/.vscode/0000755000175100001660000000000015023360710014303 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.vscode/launch.json0000644000175100001660000000442315023360704016456 0ustar00runnerdocker{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Python Debugger: PyiCloud List (password)", "type": "debugpy", "request": "launch", "module": "pyicloud.cmdline", "args": [ "--username", "${input:username}", "--password", "${input:password}", "--list", "-n", "--debug" ] }, { "name": "Python Debugger: PyiCloud List (no password)", "type": "debugpy", "request": "launch", "module": "pyicloud.cmdline", "args": [ "--username", "${input:username}", "--list", "-n", "--debug" ], "justMyCode": false }, { "name": "Python: Debug Tests", "type": "debugpy", "request": "launch", "program": "${file}", "purpose": [ "debug-test" ], "justMyCode": false }, { "name": "Python: Debug Current File", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", "justMyCode": false }, { "name": "Python Debugger: End to End", "type": "debugpy", "request": "launch", "program": "examples.py", "args": [ "--username", "${input:username}", "--password", "${input:password}" ], "cwd": "${workspaceFolder}" } ], "inputs": [ { "id": "username", "type": "promptString", "description": "Enter your iCloud username" }, { "id": "password", "type": "promptString", "description": "Enter your password", "password": true } ] } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.vscode/settings.json0000644000175100001660000000204215023360704017037 0ustar00runnerdocker{ "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.eol": "\n", "files.trimTrailingWhitespace": true, "python.testing.pytestEnabled": true, "python.analysis.inlayHints.pytestParameters": true, "isort.check": true, "isort.importStrategy": "useBundled", "python.analysis.autoFormatStrings": true, "python.analysis.autoImportCompletions": true, "python.analysis.completeFunctionParens": true, "python.createEnvironment.contentButton": "show", "python.terminal.focusAfterLaunch": true, "sonarlint.connectedMode.project": { "connectionId": "timlaing", "projectKey": "timlaing_pyicloud" }, "python.terminal.activateEnvInCurrentTerminal": true, "python.defaultInterpreterPath": "./.venv/bin/python", "git.autofetch": true, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, "[dockerfile]": { "editor.defaultFormatter": "ms-azuretools.vscode-docker" }, "pylint.importStrategy": "useBundled", "python.analysis.diagnosticMode": "workspace" } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/.vscode/tasks.json0000644000175100001660000000111615023360704016325 0ustar00runnerdocker{ "version": "2.0.0", "tasks": [ { "label": "Install requirements", "type": "shell", "command": "pip3 install --user -r requirements_all.txt", "problemMatcher": [] }, { "label": "Unit tests", "type": "shell", "command": "pytest --cov=. --cov-report xml:coverage.xml", "problemMatcher": [], "dependsOn": "Install requirements" }, { "label": "Reformat code", "type": "shell", "command": "isort . && ruff format", "problemMatcher": [], "dependsOn": "Install requirements" } ] } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/CODE_SAMPLES.md0000644000175100001660000001323315023360704015167 0ustar00runnerdocker# Code samples ## From [@Quentame](https://github.com/Quentame) pyicloud version: 0.9.6 ### Configuration: 2SA + store cookie https://github.com/home-assistant/core/blob/dev/homeassistant/components/icloud/config_flow.py ### Utilization: fetches https://github.com/home-assistant/core/blob/dev/homeassistant/components/icloud/account.py ## From [@toothrobber](https://github.com/toothrobber) pyicloud version: 0.9.1 ```python import os import click import datetime from pyicloud import PyiCloudService print("Setup Time Zone") time.strftime("%X %x %Z") os.environ["TZ"] = "America/New_York" print("Py iCloud Services") api = PyiCloudService("your@me.com", "password") if api.requires_2fa: print("Two-factor authentication required. Your trusted devices are:") devices = api.trusted_devices for i, device in enumerate(devices): print( " %s: %s" % (i, device.get("deviceName", "SMS to %s" % device.get("phoneNumber"))) ) device = click.prompt("Which device would you like to use?", default=0) device = devices[device] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt("Please enter validation code") if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) # # Devices # print("Devices") print(api.devices) print(api.devices[0]) print(api.iphone) # # Location # print("Location") print(api.iphone.location()) # # Status # print("Status") print(api.iphone.status()) # # Play Sound # # api.iphone.play_sound() # # Events # print("Events") print(api.calendar.events()) from_dt = datetime.date(2018, 1, 1) to_dt = datetime.date(2018, 1, 31) print(api.calendar.events(from_dt, to_dt)) # ======== # Contacts # ======== print("Contacts") for c in api.contacts.all(): print(c.get("firstName"), c.get("phones")) # ======================= # File Storage (Ubiquity) # ======================= # You can access documents stored in your iCloud account by using the # ``files`` property's ``dir`` method: print("File Storage") print(api.files.dir()) ``` ## From [@ixs](https://github.com/ixs) ### Debug build of pyicloud This example allows to use tools like mitmproxy, fiddler, charles or similiar things to debug the data sent on the wire. In addition, the underlying requests module and the http.client are asked, to output all data sent and received to stdout. This uses code taken from [How do I disable the security certificate check in Python requests](https://stackoverflow.com/questions/15445981/how-do-i-disable-the-security-certificate-check-in-python-requests) and [Log all requests from the python-requests module](https://stackoverflow.com/questions/16337511/log-all-requests-from-the-python-requests-module) ```python #!/usr/bin/env python3 import contextlib import http.client import logging import requests import warnings from pprint import pprint from pyicloud import PyiCloudService from urllib3.exceptions import InsecureRequestWarning # Handle certificate warnings by ignoring them old_merge_environment_settings = requests.Session.merge_environment_settings @contextlib.contextmanager def no_ssl_verification(): opened_adapters = set() def merge_environment_settings(self, url, proxies, stream, verify, cert): # Verification happens only once per connection so we need to close # all the opened adapters once we're done. Otherwise, the effects of # verify=False persist beyond the end of this context manager. opened_adapters.add(self.get_adapter(url)) settings = old_merge_environment_settings( self, url, proxies, stream, verify, cert ) settings["verify"] = False return settings requests.Session.merge_environment_settings = merge_environment_settings try: with warnings.catch_warnings(): warnings.simplefilter("ignore", InsecureRequestWarning) yield finally: requests.Session.merge_environment_settings = old_merge_environment_settings for adapter in opened_adapters: try: adapter.close() except: pass # Monkeypatch the http client for full debugging output httpclient_logger = logging.getLogger("http.client") def httpclient_logging_patch(level=logging.DEBUG): """Enable HTTPConnection debug logging to the logging framework""" def httpclient_log(*args): httpclient_logger.log(level, " ".join(args)) # mask the print() built-in in the http.client module to use # logging instead http.client.print = httpclient_log # enable debugging http.client.HTTPConnection.debuglevel = 1 # Enable general debug logging logging.basicConfig(level=logging.DEBUG) httpclient_logging_patch() api = PyiCloudService(username, password) if api.requires_2sa: print("Two-factor authentication required. Your trusted devices are:") devices = api.trusted_devices for i, device in enumerate(devices): print( " %s: %s" % (i, device.get("deviceName", "SMS to %s") % device.get("phoneNumber")) ) device = click.prompt("Which device would you like to use?", default=0) device = devices[device] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt("Please enter validation code") if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) # This request will not fail, even if using intercepting proxies. with no_ssl_verification(): pprint(api.account) ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/LICENSE.txt0000644000175100001660000000207715023360704014576 0ustar00runnerdockerThe MIT License (MIT) Copyright (c) 2025 The PyiCloud Authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6185791 pyicloud-2.0.1/PKG-INFO0000644000175100001660000006256715023360710014057 0ustar00runnerdockerMetadata-Version: 2.4 Name: pyicloud Version: 2.0.1 Summary: PyiCloud is a module which allows pythonistas to interact with iCloud webservices. Author: The PyiCloud Authors Project-URL: homepage, https://github.com/timlaing/pyicloud Project-URL: download, https://github.com/timlaing/pyicloud/releases/latest Project-URL: bug_tracker, https://github.com/timlaing/pyicloud/issues Project-URL: repository, https://github.com/timlaing/pyicloud Keywords: icloud,find-my-iphone Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Software Development :: Libraries Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: certifi>=2024.12.14 Requires-Dist: click>=8.1.8 Requires-Dist: fido2<2.0.0,>=1.2.0 Requires-Dist: keyring>=25.6.0 Requires-Dist: keyrings.alt>=5.0.2 Requires-Dist: requests>=2.31.0 Requires-Dist: srp>=1.0.21 Requires-Dist: tzlocal==5.3.1 Provides-Extra: test Requires-Dist: isort>=5.11.5; extra == "test" Requires-Dist: pre-commit>=2.21.0; extra == "test" Requires-Dist: pylint>=3.3.4; extra == "test" Requires-Dist: pylint-strict-informational>=0.1; extra == "test" Requires-Dist: pytest>=8.3.5; extra == "test" Requires-Dist: pytest-cov>=4.1.0; extra == "test" Requires-Dist: pytest-socket>=0.6.0; extra == "test" Requires-Dist: ruff>=0.9.9; extra == "test" Dynamic: license-file # pyiCloud ![Build Status](https://github.com/timlaing/pyicloud/actions/workflows/tests.yml/badge.svg) [![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) [![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) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=bugs)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=coverage)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![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 [requests](https://github.com/kennethreitz/requests) HTTP library. At its core, PyiCloud connects to iCloud using your username and password, then performs calendar and iPhone queries against their API. For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/YFvV8nbk) ## Authentication Authentication without using a saved password is as simple as passing your username and password to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password') ``` In the event that the username/password combination is invalid, a `PyiCloudFailedLoginException` exception is thrown. If the country/region setting of your Apple ID is China mainland, you should pass `china_mainland=True` to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password', china_mainland=True) ``` You can also store your password in the system keyring using the command-line tool: ``` console $ icloud --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. ``` 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: ``` console $ icloud --username=jappleseed@apple.com --delete-from-keyring ``` **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. ### Two-step and two-factor authentication (2SA/2FA) If you have enabled two-factor authentications (2FA) or [two-step authentication (2SA)](https://support.apple.com/en-us/HT204152) for the account you will have to do some extra work: ``` python if api.requires_2fa: security_key_names = api.security_key_names if security_key_names: print( f"Security key confirmation is required. " f"Please plug in one of the following keys: {', '.join(security_key_names)}" ) devices = api.fido2_devices print("Available FIDO2 devices:") for idx, dev in enumerate(devices, start=1): print(f"{idx}: {dev}") choice = click.prompt( "Select a FIDO2 device by number", type=click.IntRange(1, len(devices)), default=1, ) selected_device = devices[choice - 1] print("Please confirm the action using the security key") api.confirm_security_key(selected_device) else: print("Two-factor authentication required.") code = input( "Enter the code you received of one of your approved devices: " ) result = api.validate_2fa_code(code) print("Code validation result: %s" % result) if not result: print("Failed to verify security code") sys.exit(1) if not api.is_trusted_session: print("Session is not trusted. Requesting trust...") result = api.trust_session() print("Session trust result %s" % result) if not result: print( "Failed to request trust. You will likely be prompted for confirmation again in the coming weeks" ) elif api.requires_2sa: import click print("Two-step authentication required. Your trusted devices are:") devices = api.trusted_devices for i, device in enumerate(devices): print( " %s: %s" % (i, device.get('deviceName', "SMS to %s" % device.get('phoneNumber'))) ) device = click.prompt('Which device would you like to use?', default=0) device = devices[device] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt('Please enter validation code') if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) ``` ## Account You can access information about your iCloud account using the `account` property: ``` pycon >>> api.account {devices: 5, family: 3, storage: 8990635296 bytes free} ``` ### Summary Plan you can access information about your iCloud account\'s summary plan using the `account.summary_plan` property: ``` pycon >>> api.account.summary_plan {'featureKey': 'cloud.storage', 'summary': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAccountPurchasedPlan': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAppleOnePlan': {'includedInPlan': False}, 'includedWithSharedPlan': {'includedInPlan': False}, 'includedWithCompedPlan': {'includedInPlan': False}, 'includedWithManagedPlan': {'includedInPlan': False}} ``` ### Storage You can get the storage information of your iCloud account using the `account.storage` property: ``` pycon >>> api.account.storage {usage: 85.12% used of 53687091200 bytes, usages_by_media: {'photos': , 'backup': , 'docs': , 'mail': , 'messages': }} ``` You even can generate a pie chart: ``` python ...... storage = api.account.storage y = [] colors = [] labels = [] for usage in storage.usages_by_media.values(): y.append(usage.usage_in_bytes) colors.append(f"#{usage.color}") labels.append(usage.label) plt.pie(y, labels=labels, colors=colors, ) plt.title("Storage Pie Test") plt.show() ``` ## Devices You can list which devices associated with your account by using the `devices` property: ``` pycon >>> api.devices { 'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': , 'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': } ``` and you can access individual devices by either their index, or their ID: ``` pycon >>> api.devices[0] >>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] ``` or, as a shorthand if you have only one associated apple device, you can simply use the `iphone` property to access the first device associated with your account: ``` pycon >>> api.iphone ``` Note: the first device associated with your account may not necessarily be your iPhone. ## Find My iPhone Once you have successfully authenticated, you can start querying your data! ### Location Returns the device\'s last known location. The Find My iPhone app must have been installed and initialized. ``` pycon >>> api.iphone.location() {'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0} ``` ### Status The Find My iPhone response is quite bloated, so for simplicity\'s sake this method will return a subset of the properties. ``` pycon >>> api.iphone.status() {'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"} ``` If you wish to request further properties, you may do so by passing in a list of property names. ### Play Sound Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg. ``` python api.iphone.play_sound() ``` A few moments later, the device will play a ringtone, display the default notification (\"Find My iPhone Alert\") and a confirmation email will be sent to you. ### Lost Mode Lost mode is slightly different to the \"Play Sound\" functionality in that it allows the person who picks up the phone to call a specific phone number *without having to enter the passcode*. Just like \"Play Sound\" you may pass a custom message which the device will display, if it\'s not overridden the custom message of \"This iPhone has been lost. Please call me.\" is used. ``` python phone_number = '555-373-383' message = 'Thief! Return my phone immediately.' api.iphone.lost_device(phone_number, message) ``` ## Calendar The calendar webservice now supports fethcing, creating, and removing calendars and events. ### Calendars The calendar functionality is based around the `CalendarObject` dataclass. Every variable has a default value named according to the http payload parameters from the icloud API. The `guid` is a uuid4 identifier unique to each calendar. The class will create one automatically if it is left blank when the `CalendarObject` is instanced. the `guid` parameter should only be set when you know the guid of an existing calendar. The color is an rgb hex value and will be a random color if not set. #### Functions **get_calendars(as_objs:bool=False) -> list**
*returns a list of the user's calendars*
if `as_objs` is set to `True`, the returned list will be of CalendarObjects; else it will be of dictionaries. **add_calendar(calendar:CalendarObject) -> None:**
*adds a calendar to the users apple calendar* **remove_calendar(cal_guid:str) -> None**
*Removes a Calendar from the apple calendar given the provided guid* #### Examples *Create and add a new calendar:* ``` python api = login("username", "pass") calendar_service = api.calendar cal = calendar_service.CalendarObject(title="My Calendar", share_type="published") cal.color = "#FF0000" calendar_service.add_calendar(cal) ``` *Remove an existing calendar:* ``` python cal = calendar_service.get_calendars(as_objs=True)[1] calendar_service.remove_calendar(cal.guid) ``` ### Events The events functionality is based around the `EventObject` dataclass. `guid` is the unique identifier of each event, while `pGuid` is the identifier of the calendar to which this event belongs. `pGuid` is the only paramter that is not optional. Some of the functionality of Events, most notably Alarms, is not included here, but could be easily done had you the desire. The `EventObject` currently has one method you may use: `add_invitees` which takes a list of emails and adds them as invitees to this event. They should recieve an email when this event is created. #### Functions **get_events(from_dt:datetime=None, to_dt:datetime=None, period:str="month", as_objs:bool=False)**
*Returns a list of events from `from_dt` to `to_dt`. If `period` is provided, it will return the events in that period refrencing `from_dt` if it was provided; else using today's date. IE if `period` is "month", the events for the entire month that `from_dt` falls within will be returned.* **get_event_detail(pguid, guid, as_obj:bool=False)**
*Returns a speciffic event given that event's `guid` and `pGuid`* **add_event(event:EventObject) -> None**
*Adds an Event to a calendar specified by the event's `pGuid`.* **remove_event(event:EventObject) -> None**
*Removes an Event from a calendar specified by the event's `pGuid`.* #### Examples *Create, add and remove an Event* ``` python calendar_service = api.calendar cal = calendar_service.get_calendars(as_objs=True)[0] event = EventObject(cal.guid, title="test", start_date=datetime.today(), end_date=datetime.today() + timedelta(hours=1)) calendar_service.add_event(event) calendar_service.remove_event(event) ``` Or, between a specific date range: ``` python from_dt = datetime(2012, 1, 1) to_dt = datetime(2012, 1, 31) api.calendar.events(from_dt, to_dt) ``` *Get next weeks' events* ``` python calendar_service.get_events(from_dt=datetime.today() + timedelta(days=7) ,period="week", as_objs=True) ``` ## Contacts 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')) John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}] ``` Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud. ### MeCard You can access the user's info (contact information) using the `me` property: ``` pycon >>> api.contacts.me Tim Cook ``` And get the user's profile picture: ``` pycon >>> api.contacts.me.photo {'signature': 'the signature', 'url': 'URL to the picture', 'crop': {'x': 0, 'width': 640, 'y': 110, 'height': 640}} ``` ## File Storage (Ubiquity) You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: ``` pycon >>> api.files.dir() ['.do-not-delete', '.localized', 'com~apple~Notes', 'com~apple~Preview', 'com~apple~mail', 'com~apple~shoebox', 'com~apple~system~spotlight' ] ``` You can access children and their children\'s children using the filename as an index: ``` pycon >>> api.files['com~apple~Notes'] >>> api.files['com~apple~Notes'].type 'folder' >>> api.files['com~apple~Notes'].dir() ['Documents'] >>> api.files['com~apple~Notes']['Documents'].dir() ['Some Document'] >>> api.files['com~apple~Notes']['Documents']['Some Document'].name 'Some Document' >>> api.files['com~apple~Notes']['Documents']['Some Document'].modified datetime.datetime(2012, 9, 13, 2, 26, 17) >>> api.files['com~apple~Notes']['Documents']['Some Document'].size 1308134 >>> api.files['com~apple~Notes']['Documents']['Some Document'].type 'file' ``` And when you have a file that you\'d like to download, the `open` method will return a response object from which you can read the `content`. ``` pycon >>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content 'Hello, these are the file contents' ``` Note: the object returned from the above `open` method is a [response object](http://www.python-requests.org/en/latest/api/#classes) and the `open` method can accept any parameters you might normally use in a request using [requests](https://github.com/kennethreitz/requests). For example, if you know that the file you\'re opening has JSON content: ``` pycon >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json() {'How much we love you': 'lots'} >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you'] 'lots' ``` Or, if you\'re downloading a particularly large file, you may want to use the `stream` keyword argument, and read directly from the raw response object: ``` pycon >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True) >>> with open('downloaded_file.zip', 'wb') as opened_file: opened_file.write(download.raw.read()) ``` ## File Storage (iCloud Drive) You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at `api.drive`: ``` pycon >>> api.drive.dir() ['Holiday Photos', 'Work Files'] >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG', 'DSC08117.JPG'] >>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'] >>> drive_file.name 'DSC08116.JPG' >>> drive_file.date_modified datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC >>> drive_file.size 2021698 >>> drive_file.type 'file' ``` The `open` method will return a response object from which you can read the file\'s contents: ``` python from shutil import copyfileobj with drive_file.open(stream=True) as response: with open(drive_file.name, 'wb') as file_out: copyfileobj(response.raw, file_out) ``` To interact with files and directions the `mkdir`, `rename` and `delete` functions are available for a file or folder: ``` python api.drive['Holiday Photos'].mkdir('2020') api.drive['Holiday Photos']['2020'].rename('2020_copy') api.drive['Holiday Photos']['2020_copy'].delete() ``` The `upload` method can be used to send a file-like object to the iCloud Drive: ``` python with open('Vacation.jpeg', 'rb') as file_in: api.drive['Holiday Photos'].upload(file_in) ``` It is strongly suggested to open file handles as binary rather than text to prevent decoding errors further down the line. You can also interact with files in the `trash`: ``` pycon >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'].delete() >>> api.drive.trash.dir() ['DSC08116.JPG'] >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08117.JPG'].delete() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() ['DSC08116.JPG', 'DSC08117.JPG'] ``` You can interact with the `trash` similar to a standard directory, with some restrictions. In addition, files in the `trash` can be recovered back to their original location, or deleted forever: ``` pycon >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() [] >>> recover_output = api.drive.trash['DSC08116.JPG'].recover() >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG'] >>> api.drive.trash.dir() ['DSC08117.JPG'] >>> purge_output = api.drive.trash['DSC08117.JPG'].delete_forever() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() [] ``` ## Photo Library You can access the iCloud Photo Library through the `photos` property. ``` pycon >>> api.photos.all ``` Individual albums are available through the `albums` property: ``` pycon >>> api.photos.albums['Screenshots'] ``` Which you can iterate to access the photo assets. The "All Photos" album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) : ``` pycon >>> for photo in api.photos.albums['Screenshots']: print(photo, photo.filename) IMG_6045.JPG ``` To download a photo use the `download` method, which will return a [Response object](https://requests.readthedocs.io/en/latest/api/#requests.Response), initialized with `stream` set to `True`, so you can read from the raw response object: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) download = photo.download() with open(photo.filename, 'wb') as opened_file: opened_file.write(download.raw.read()) ``` Consider using `shutil.copyfileobj` or another buffered strategy for downloading so that the whole file isn't read into memory before writing. ``` python import shutil photo = next(iter(api.photos.albums['Screenshots']), None) response_obj = photo.download() with open(photo.filename, 'wb') as f: shutil.copyfileobj(response_obj.raw, f) ``` Information about each version can be accessed through the `versions` property: ``` pycon >>> photo.versions.keys() ['medium', 'original', 'thumb'] ``` To download a specific version of the photo asset, pass the version to `download()`: ``` python download = photo.download('thumb') with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(download.raw.read()) ``` To upload an image ``` python api.photos.upload_file(file_path) ``` Note: Only limited media type is accepted, upload not support types (e.g. png) will get TYPE_UNSUPPORTED error. ## Hide My Email You can access the iCloud Hide My Email service through the `hidemyemail` property To generate a new email alias use the `generate` method. ```python # Generate a new email alias new_email = api.hidemyemail.generate() print(f"Generated new email: {new_email}") ``` To reserve the generated email with a custom label ```python reserved = api.hidemyemail.reserve(new_email, "Shopping") print(f"Reserved email - response: {reserved}") ``` To get the anonymous_id (unique identifier) from the reservation. ``` python anonymous_id = reserved.get("anonymousId") print(anonymous_id) ``` To list the current aliases ``` python # Print details of each alias for alias in api.hidemyemail: print(f"- {alias.get('hme')}: {alias.get('label')} ({alias.get('anonymousId')})") ``` Additional detail usage ```python # Get detailed information about a specific alias alias_details = api.hidemyemail[anonymous_id] print(f"Alias details: {alias_details}") # Update the alias metadata (label and note) updated = api.hidemyemail.update_metadata( anonymous_id, "Online Shopping", "Used for e-commerce websites" ) print(f"Updated alias: {updated}") # Deactivate an alias (stops email forwarding but keeps the alias for future reactivation) deactivated = api.hidemyemail.deactivate(anonymous_id) print(f"Deactivated alias: {deactivated}") # Reactivate a previously deactivated alias (resumes email forwarding) reactivated = api.hidemyemail.reactivate(anonymous_id) print(f"Reactivated alias: {reactivated}") # Delete the alias when no longer needed deleted = api.hidemyemail.delete(anonymous_id) print(f"Deleted alias: {deleted}") ``` ## Examples If you want to see some code samples, see the [examples](/examples.py). ` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/README.md0000644000175100001660000005706015023360704014234 0ustar00runnerdocker# pyiCloud ![Build Status](https://github.com/timlaing/pyicloud/actions/workflows/tests.yml/badge.svg) [![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) [![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) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=bugs)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=coverage)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![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 [requests](https://github.com/kennethreitz/requests) HTTP library. At its core, PyiCloud connects to iCloud using your username and password, then performs calendar and iPhone queries against their API. For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/YFvV8nbk) ## Authentication Authentication without using a saved password is as simple as passing your username and password to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password') ``` In the event that the username/password combination is invalid, a `PyiCloudFailedLoginException` exception is thrown. If the country/region setting of your Apple ID is China mainland, you should pass `china_mainland=True` to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password', china_mainland=True) ``` You can also store your password in the system keyring using the command-line tool: ``` console $ icloud --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. ``` 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: ``` console $ icloud --username=jappleseed@apple.com --delete-from-keyring ``` **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. ### Two-step and two-factor authentication (2SA/2FA) If you have enabled two-factor authentications (2FA) or [two-step authentication (2SA)](https://support.apple.com/en-us/HT204152) for the account you will have to do some extra work: ``` python if api.requires_2fa: security_key_names = api.security_key_names if security_key_names: print( f"Security key confirmation is required. " f"Please plug in one of the following keys: {', '.join(security_key_names)}" ) devices = api.fido2_devices print("Available FIDO2 devices:") for idx, dev in enumerate(devices, start=1): print(f"{idx}: {dev}") choice = click.prompt( "Select a FIDO2 device by number", type=click.IntRange(1, len(devices)), default=1, ) selected_device = devices[choice - 1] print("Please confirm the action using the security key") api.confirm_security_key(selected_device) else: print("Two-factor authentication required.") code = input( "Enter the code you received of one of your approved devices: " ) result = api.validate_2fa_code(code) print("Code validation result: %s" % result) if not result: print("Failed to verify security code") sys.exit(1) if not api.is_trusted_session: print("Session is not trusted. Requesting trust...") result = api.trust_session() print("Session trust result %s" % result) if not result: print( "Failed to request trust. You will likely be prompted for confirmation again in the coming weeks" ) elif api.requires_2sa: import click print("Two-step authentication required. Your trusted devices are:") devices = api.trusted_devices for i, device in enumerate(devices): print( " %s: %s" % (i, device.get('deviceName', "SMS to %s" % device.get('phoneNumber'))) ) device = click.prompt('Which device would you like to use?', default=0) device = devices[device] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt('Please enter validation code') if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) ``` ## Account You can access information about your iCloud account using the `account` property: ``` pycon >>> api.account {devices: 5, family: 3, storage: 8990635296 bytes free} ``` ### Summary Plan you can access information about your iCloud account\'s summary plan using the `account.summary_plan` property: ``` pycon >>> api.account.summary_plan {'featureKey': 'cloud.storage', 'summary': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAccountPurchasedPlan': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAppleOnePlan': {'includedInPlan': False}, 'includedWithSharedPlan': {'includedInPlan': False}, 'includedWithCompedPlan': {'includedInPlan': False}, 'includedWithManagedPlan': {'includedInPlan': False}} ``` ### Storage You can get the storage information of your iCloud account using the `account.storage` property: ``` pycon >>> api.account.storage {usage: 85.12% used of 53687091200 bytes, usages_by_media: {'photos': , 'backup': , 'docs': , 'mail': , 'messages': }} ``` You even can generate a pie chart: ``` python ...... storage = api.account.storage y = [] colors = [] labels = [] for usage in storage.usages_by_media.values(): y.append(usage.usage_in_bytes) colors.append(f"#{usage.color}") labels.append(usage.label) plt.pie(y, labels=labels, colors=colors, ) plt.title("Storage Pie Test") plt.show() ``` ## Devices You can list which devices associated with your account by using the `devices` property: ``` pycon >>> api.devices { 'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': , 'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': } ``` and you can access individual devices by either their index, or their ID: ``` pycon >>> api.devices[0] >>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] ``` or, as a shorthand if you have only one associated apple device, you can simply use the `iphone` property to access the first device associated with your account: ``` pycon >>> api.iphone ``` Note: the first device associated with your account may not necessarily be your iPhone. ## Find My iPhone Once you have successfully authenticated, you can start querying your data! ### Location Returns the device\'s last known location. The Find My iPhone app must have been installed and initialized. ``` pycon >>> api.iphone.location() {'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0} ``` ### Status The Find My iPhone response is quite bloated, so for simplicity\'s sake this method will return a subset of the properties. ``` pycon >>> api.iphone.status() {'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"} ``` If you wish to request further properties, you may do so by passing in a list of property names. ### Play Sound Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg. ``` python api.iphone.play_sound() ``` A few moments later, the device will play a ringtone, display the default notification (\"Find My iPhone Alert\") and a confirmation email will be sent to you. ### Lost Mode Lost mode is slightly different to the \"Play Sound\" functionality in that it allows the person who picks up the phone to call a specific phone number *without having to enter the passcode*. Just like \"Play Sound\" you may pass a custom message which the device will display, if it\'s not overridden the custom message of \"This iPhone has been lost. Please call me.\" is used. ``` python phone_number = '555-373-383' message = 'Thief! Return my phone immediately.' api.iphone.lost_device(phone_number, message) ``` ## Calendar The calendar webservice now supports fethcing, creating, and removing calendars and events. ### Calendars The calendar functionality is based around the `CalendarObject` dataclass. Every variable has a default value named according to the http payload parameters from the icloud API. The `guid` is a uuid4 identifier unique to each calendar. The class will create one automatically if it is left blank when the `CalendarObject` is instanced. the `guid` parameter should only be set when you know the guid of an existing calendar. The color is an rgb hex value and will be a random color if not set. #### Functions **get_calendars(as_objs:bool=False) -> list**
*returns a list of the user's calendars*
if `as_objs` is set to `True`, the returned list will be of CalendarObjects; else it will be of dictionaries. **add_calendar(calendar:CalendarObject) -> None:**
*adds a calendar to the users apple calendar* **remove_calendar(cal_guid:str) -> None**
*Removes a Calendar from the apple calendar given the provided guid* #### Examples *Create and add a new calendar:* ``` python api = login("username", "pass") calendar_service = api.calendar cal = calendar_service.CalendarObject(title="My Calendar", share_type="published") cal.color = "#FF0000" calendar_service.add_calendar(cal) ``` *Remove an existing calendar:* ``` python cal = calendar_service.get_calendars(as_objs=True)[1] calendar_service.remove_calendar(cal.guid) ``` ### Events The events functionality is based around the `EventObject` dataclass. `guid` is the unique identifier of each event, while `pGuid` is the identifier of the calendar to which this event belongs. `pGuid` is the only paramter that is not optional. Some of the functionality of Events, most notably Alarms, is not included here, but could be easily done had you the desire. The `EventObject` currently has one method you may use: `add_invitees` which takes a list of emails and adds them as invitees to this event. They should recieve an email when this event is created. #### Functions **get_events(from_dt:datetime=None, to_dt:datetime=None, period:str="month", as_objs:bool=False)**
*Returns a list of events from `from_dt` to `to_dt`. If `period` is provided, it will return the events in that period refrencing `from_dt` if it was provided; else using today's date. IE if `period` is "month", the events for the entire month that `from_dt` falls within will be returned.* **get_event_detail(pguid, guid, as_obj:bool=False)**
*Returns a speciffic event given that event's `guid` and `pGuid`* **add_event(event:EventObject) -> None**
*Adds an Event to a calendar specified by the event's `pGuid`.* **remove_event(event:EventObject) -> None**
*Removes an Event from a calendar specified by the event's `pGuid`.* #### Examples *Create, add and remove an Event* ``` python calendar_service = api.calendar cal = calendar_service.get_calendars(as_objs=True)[0] event = EventObject(cal.guid, title="test", start_date=datetime.today(), end_date=datetime.today() + timedelta(hours=1)) calendar_service.add_event(event) calendar_service.remove_event(event) ``` Or, between a specific date range: ``` python from_dt = datetime(2012, 1, 1) to_dt = datetime(2012, 1, 31) api.calendar.events(from_dt, to_dt) ``` *Get next weeks' events* ``` python calendar_service.get_events(from_dt=datetime.today() + timedelta(days=7) ,period="week", as_objs=True) ``` ## Contacts 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')) John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}] ``` Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud. ### MeCard You can access the user's info (contact information) using the `me` property: ``` pycon >>> api.contacts.me Tim Cook ``` And get the user's profile picture: ``` pycon >>> api.contacts.me.photo {'signature': 'the signature', 'url': 'URL to the picture', 'crop': {'x': 0, 'width': 640, 'y': 110, 'height': 640}} ``` ## File Storage (Ubiquity) You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: ``` pycon >>> api.files.dir() ['.do-not-delete', '.localized', 'com~apple~Notes', 'com~apple~Preview', 'com~apple~mail', 'com~apple~shoebox', 'com~apple~system~spotlight' ] ``` You can access children and their children\'s children using the filename as an index: ``` pycon >>> api.files['com~apple~Notes'] >>> api.files['com~apple~Notes'].type 'folder' >>> api.files['com~apple~Notes'].dir() ['Documents'] >>> api.files['com~apple~Notes']['Documents'].dir() ['Some Document'] >>> api.files['com~apple~Notes']['Documents']['Some Document'].name 'Some Document' >>> api.files['com~apple~Notes']['Documents']['Some Document'].modified datetime.datetime(2012, 9, 13, 2, 26, 17) >>> api.files['com~apple~Notes']['Documents']['Some Document'].size 1308134 >>> api.files['com~apple~Notes']['Documents']['Some Document'].type 'file' ``` And when you have a file that you\'d like to download, the `open` method will return a response object from which you can read the `content`. ``` pycon >>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content 'Hello, these are the file contents' ``` Note: the object returned from the above `open` method is a [response object](http://www.python-requests.org/en/latest/api/#classes) and the `open` method can accept any parameters you might normally use in a request using [requests](https://github.com/kennethreitz/requests). For example, if you know that the file you\'re opening has JSON content: ``` pycon >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json() {'How much we love you': 'lots'} >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you'] 'lots' ``` Or, if you\'re downloading a particularly large file, you may want to use the `stream` keyword argument, and read directly from the raw response object: ``` pycon >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True) >>> with open('downloaded_file.zip', 'wb') as opened_file: opened_file.write(download.raw.read()) ``` ## File Storage (iCloud Drive) You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at `api.drive`: ``` pycon >>> api.drive.dir() ['Holiday Photos', 'Work Files'] >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG', 'DSC08117.JPG'] >>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'] >>> drive_file.name 'DSC08116.JPG' >>> drive_file.date_modified datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC >>> drive_file.size 2021698 >>> drive_file.type 'file' ``` The `open` method will return a response object from which you can read the file\'s contents: ``` python from shutil import copyfileobj with drive_file.open(stream=True) as response: with open(drive_file.name, 'wb') as file_out: copyfileobj(response.raw, file_out) ``` To interact with files and directions the `mkdir`, `rename` and `delete` functions are available for a file or folder: ``` python api.drive['Holiday Photos'].mkdir('2020') api.drive['Holiday Photos']['2020'].rename('2020_copy') api.drive['Holiday Photos']['2020_copy'].delete() ``` The `upload` method can be used to send a file-like object to the iCloud Drive: ``` python with open('Vacation.jpeg', 'rb') as file_in: api.drive['Holiday Photos'].upload(file_in) ``` It is strongly suggested to open file handles as binary rather than text to prevent decoding errors further down the line. You can also interact with files in the `trash`: ``` pycon >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'].delete() >>> api.drive.trash.dir() ['DSC08116.JPG'] >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08117.JPG'].delete() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() ['DSC08116.JPG', 'DSC08117.JPG'] ``` You can interact with the `trash` similar to a standard directory, with some restrictions. In addition, files in the `trash` can be recovered back to their original location, or deleted forever: ``` pycon >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() [] >>> recover_output = api.drive.trash['DSC08116.JPG'].recover() >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG'] >>> api.drive.trash.dir() ['DSC08117.JPG'] >>> purge_output = api.drive.trash['DSC08117.JPG'].delete_forever() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() [] ``` ## Photo Library You can access the iCloud Photo Library through the `photos` property. ``` pycon >>> api.photos.all ``` Individual albums are available through the `albums` property: ``` pycon >>> api.photos.albums['Screenshots'] ``` Which you can iterate to access the photo assets. The "All Photos" album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) : ``` pycon >>> for photo in api.photos.albums['Screenshots']: print(photo, photo.filename) IMG_6045.JPG ``` To download a photo use the `download` method, which will return a [Response object](https://requests.readthedocs.io/en/latest/api/#requests.Response), initialized with `stream` set to `True`, so you can read from the raw response object: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) download = photo.download() with open(photo.filename, 'wb') as opened_file: opened_file.write(download.raw.read()) ``` Consider using `shutil.copyfileobj` or another buffered strategy for downloading so that the whole file isn't read into memory before writing. ``` python import shutil photo = next(iter(api.photos.albums['Screenshots']), None) response_obj = photo.download() with open(photo.filename, 'wb') as f: shutil.copyfileobj(response_obj.raw, f) ``` Information about each version can be accessed through the `versions` property: ``` pycon >>> photo.versions.keys() ['medium', 'original', 'thumb'] ``` To download a specific version of the photo asset, pass the version to `download()`: ``` python download = photo.download('thumb') with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(download.raw.read()) ``` To upload an image ``` python api.photos.upload_file(file_path) ``` Note: Only limited media type is accepted, upload not support types (e.g. png) will get TYPE_UNSUPPORTED error. ## Hide My Email You can access the iCloud Hide My Email service through the `hidemyemail` property To generate a new email alias use the `generate` method. ```python # Generate a new email alias new_email = api.hidemyemail.generate() print(f"Generated new email: {new_email}") ``` To reserve the generated email with a custom label ```python reserved = api.hidemyemail.reserve(new_email, "Shopping") print(f"Reserved email - response: {reserved}") ``` To get the anonymous_id (unique identifier) from the reservation. ``` python anonymous_id = reserved.get("anonymousId") print(anonymous_id) ``` To list the current aliases ``` python # Print details of each alias for alias in api.hidemyemail: print(f"- {alias.get('hme')}: {alias.get('label')} ({alias.get('anonymousId')})") ``` Additional detail usage ```python # Get detailed information about a specific alias alias_details = api.hidemyemail[anonymous_id] print(f"Alias details: {alias_details}") # Update the alias metadata (label and note) updated = api.hidemyemail.update_metadata( anonymous_id, "Online Shopping", "Used for e-commerce websites" ) print(f"Updated alias: {updated}") # Deactivate an alias (stops email forwarding but keeps the alias for future reactivation) deactivated = api.hidemyemail.deactivate(anonymous_id) print(f"Deactivated alias: {deactivated}") # Reactivate a previously deactivated alias (resumes email forwarding) reactivated = api.hidemyemail.reactivate(anonymous_id) print(f"Reactivated alias: {reactivated}") # Delete the alias when no longer needed deleted = api.hidemyemail.delete(anonymous_id) print(f"Deleted alias: {deleted}") ``` ## Examples If you want to see some code samples, see the [examples](/examples.py). ` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/examples.py0000644000175100001660000002254015023360704015140 0ustar00runnerdocker"""End to End System test""" import argparse import json import sys from typing import Any, List, Optional import click from fido2.hid import CtapHidDevice from requests import Response from pyicloud import PyiCloudService from pyicloud.exceptions import PyiCloudServiceUnavailable from pyicloud.services.calendar import CalendarObject, CalendarService END_LIST = "End List\n" MAX_DISPLAY = 10 def parse_args() -> argparse.Namespace: """Parse command line arguments""" parser = argparse.ArgumentParser(description="End to End Test of Services") 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", ) return parser.parse_args() def handle_2fa(api: PyiCloudService) -> None: """Handle two-factor authentication""" security_key_names: Optional[List[str]] = api.security_key_names if security_key_names: print( f"Security key confirmation is required. " f"Please plug in one of the following keys: {', '.join(security_key_names)}" ) fido2_devices: List[CtapHidDevice] = api.fido2_devices print("Available FIDO2 devices:") for idx, dev in enumerate(fido2_devices, start=1): print(f"{idx}: {dev}") choice = click.prompt( "Select a FIDO2 device by number", type=click.IntRange(1, len(fido2_devices)), default=1, ) selected_device: CtapHidDevice = fido2_devices[choice - 1] print("Please confirm the action using the security key") api.confirm_security_key(selected_device) else: print("Two-factor authentication required.") code: str = input( "Enter the code you received of one of your approved devices: " ) result: bool = api.validate_2fa_code(code) print(f"Code validation result: {result}") if not result: print("Failed to verify security code") sys.exit(1) if not api.is_trusted_session: print("Session is not trusted. Requesting trust...") result = api.trust_session() print(f"Session trust result: {result}") if not result: print( "Failed to request trust. You will likely be prompted for confirmation again in the coming weeks" ) def handle_2sa(api: PyiCloudService) -> None: """Handle two-step authentication""" print("Two-step authentication required. Your trusted devices are:") trusted_devices: List[dict[str, Any]] = api.trusted_devices for i, device in enumerate(trusted_devices): print( " %s: %s" % (i, device.get("deviceName", "SMS to %s" % device.get("phoneNumber"))) ) device_index: int = click.prompt("Which device would you like to use?", default=0) device: dict[str, Any] = trusted_devices[device_index] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt("Please enter validation code") if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) def get_api() -> PyiCloudService: """Get the PyiCloud API""" args: argparse.Namespace = parse_args() api = PyiCloudService( apple_id=args.username, password=args.password, china_mainland=args.china_mainland, ) if api.requires_2fa: handle_2fa(api) elif api.requires_2sa: handle_2sa(api) return api def display_devices(api: PyiCloudService) -> None: """Display device info""" print(f"List of devices ({len(api.devices)}):") for idx, device in enumerate(api.devices): print(f"\t{idx}: {device}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) print("First device:") print(f"\t Name: {api.iphone}") print(f"\t Location: {json.dumps(api.iphone.location, indent=4)}\n") def display_calendars(api: PyiCloudService) -> None: """Display calendar info""" calendar_service: CalendarService = api.calendar calendars: list[CalendarObject] = calendar_service.get_calendars(as_objs=True) print(f"List of calendars ({len(calendars)}):") for idx, calendar in enumerate(calendars): print(f"\t{idx}: {calendar.title}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_contacts(api: PyiCloudService) -> None: """Display contacts info""" contacts = api.contacts.all if contacts: print(f"List of contacts ({len(contacts)}):") for idx, contact in enumerate(contacts): print( f"\t{idx}: {contact.get('firstName') or contact.get('lastName') or contact.get('companyName')}" ) if idx >= MAX_DISPLAY - 1: break print(END_LIST) else: print("No contacts found\n") def display_drive(api: PyiCloudService) -> None: """Display drive info""" drive_files: list[str] = api.drive.dir() print(f"List of files in iCloud Drive root ({len(drive_files)}):") for idx, filename in enumerate(drive_files): print(f"\t{idx}: {filename} ({api.drive[filename].type})") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_files(api: PyiCloudService) -> None: """Display files info""" try: files: list[str] = api.files.dir() print(f"List of files in iCloud files root ({len(files)}):") for idx, filename in enumerate(files): print(f"\t{idx}: {filename} ({api.files[filename].type})") if idx >= MAX_DISPLAY - 1: break print(END_LIST) except PyiCloudServiceUnavailable as error: print(f"Files service not available: {error}\n") def display_photos(api: PyiCloudService) -> None: """Display photo info""" print(f"List of photo albums ({len(api.photos.albums)}):") for idx, album in enumerate(api.photos.albums): print(f"\t{idx}: {album}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) print(f"List of ALL PHOTOS ({len(api.photos.all)}):") for idx, photo in enumerate(api.photos.all): print(f"\t{idx}: {photo.filename} ({photo.item_type})") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_videos(api: PyiCloudService) -> None: """Display video info""" print(f"List of Videos ({len(api.photos.albums['Videos'])}):") for idx, photo in enumerate(api.photos.albums["Videos"]): print(f"\t{idx}: {photo.filename} ({photo.item_type})") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_shared_photos(api: PyiCloudService) -> None: """Display shared photo info""" album = None print(f"List of Shared Albums ({len(api.photos.shared_streams)}):") for idx, album in enumerate(api.photos.shared_streams): print(f"\t{idx}: {album}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) if album and api.photos.shared_streams: print( f"List of Shared Photos [{album}] ({len(api.photos.shared_streams[album])}):" ) for idx, photo in enumerate(api.photos.shared_streams[album]): print(f"\t{idx}: {photo.filename} ({photo.item_type})") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_account(api: PyiCloudService) -> None: """Display account info""" print(f"Account name: {api.account_name}") print(f"Account plan: {json.dumps(api.account.summary_plan, indent=4)}") print(f"List of Family Member ({len(api.account.family)}):") for idx, member in enumerate(api.account.family): print(f"\t{idx}: {member}") photo: Response = member.get_photo() print(f"\t\tPhoto: {photo}") print(f"\t\tPhoto type: {photo.headers['Content-Type']}") print(f"\t\tPhoto size: {photo.headers['Content-Length']}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) def display_hidemyemail(api: PyiCloudService) -> None: """Display Hide My Email info""" print(f"List of Hide My Email ({len(api.hidemyemail)}):") for idx, email in enumerate(api.hidemyemail): print( f"\t{idx}: {email['hme']} ({email['domain']}) Active = {email['isActive']}" ) if idx >= MAX_DISPLAY - 1: break print(END_LIST) def main() -> None: """main function""" api: PyiCloudService = get_api() display_hidemyemail(api) display_account(api) try: display_calendars(api) except PyiCloudServiceUnavailable as error: print(f"Calendar service not available: {error}\n") display_files(api) display_devices(api) display_contacts(api) display_drive(api) display_photos(api) display_videos(api) display_shared_photos(api) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1749934535.609579 pyicloud-2.0.1/pyicloud/0000755000175100001660000000000015023360710014572 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6105793 pyicloud-2.0.1/pyicloud/.vscode/0000755000175100001660000000000015023360710016133 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/.vscode/launch.json0000644000175100001660000000075015023360704020305 0ustar00runnerdocker{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "command line", "type": "python", "request": "launch", "program": "${file}", "console": "integratedTerminal", "justMyCode": true } ] } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/__init__.py0000644000175100001660000000035015023360704016704 0ustar00runnerdocker"""The pyiCloud library.""" import logging from pyicloud.base import AppleDevice, PyiCloudService logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__: list[str] = [ "PyiCloudService", "AppleDevice", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/base.py0000644000175100001660000006751715023360704016101 0ustar00runnerdocker"""Library base file.""" import base64 import getpass import hashlib import logging from os import environ, mkdir, path from tempfile import gettempdir from typing import Any, Dict, List, Optional from uuid import uuid1 import srp from fido2.client import Fido2Client from fido2.hid import CtapHidDevice from fido2.webauthn import ( AuthenticatorAssertionResponse, PublicKeyCredentialDescriptor, PublicKeyCredentialRequestOptions, PublicKeyCredentialType, ) from requests import HTTPError from requests.models import Response from pyicloud.const import ACCOUNT_NAME, CONTENT_TYPE_JSON from pyicloud.exceptions import ( PyiCloud2FARequiredException, PyiCloudAPIResponseException, PyiCloudFailedLoginException, PyiCloudPasswordException, PyiCloudServiceNotActivatedException, PyiCloudServiceUnavailable, ) from pyicloud.services import ( AccountService, AppleDevice, CalendarService, ContactsService, DriveService, FindMyiPhoneServiceManager, HideMyEmailService, PhotosService, RemindersService, UbiquityService, ) from pyicloud.session import PyiCloudSession from pyicloud.utils import get_password_from_keyring LOGGER: logging.Logger = logging.getLogger(__name__) def b64url_decode(s: str) -> bytes: """Decode a base64url encoded string.""" return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def b64_encode(b: bytes) -> str: """Encode bytes to a base64url encoded string.""" return base64.b64encode(b).decode() class SrpPassword: """SRP password.""" def __init__(self, password: str) -> None: self.password: str = password self.salt: bytes self.iterations: int self.key_length: int def set_encrypt_info(self, salt: bytes, iterations: int, key_length: int) -> None: """Set encrypt info.""" self.salt: bytes = salt self.iterations: int = iterations self.key_length: int = key_length def encode(self) -> bytes: """Encode password.""" password_hash: bytes = hashlib.sha256(self.password.encode("utf-8")).digest() return hashlib.pbkdf2_hmac( "sha256", password_hash, self.salt, self.iterations, self.key_length, ) class PyiCloudPasswordFilter(logging.Filter): """Password log hider.""" def __init__(self, password: str) -> None: super().__init__(password) def filter(self, record) -> bool: message: str = record.getMessage() if self.name in message: record.msg = message.replace(self.name, "*" * 8) record.args = () return True class PyiCloudService(object): """ A base authentication class for the iCloud service. Handles the authentication required to access iCloud services. Usage: from pyicloud import PyiCloudService pyicloud = PyiCloudService('username@apple.com', 'password') pyicloud.iphone.location() """ def _setup_endpoints(self) -> None: """Set up the endpoints for the service.""" # If the country or region setting of your Apple ID is China mainland. # See https://support.apple.com/en-us/HT208351 icloud_china: str = ".cn" if self._is_china_mainland else "" self.auth_endpoint: str = ( f"https://idmsa.apple.com{icloud_china}/appleauth/auth" ) self.home_endpoint: str = f"https://www.icloud.com{icloud_china}" self.setup_endpoint: str = f"https://setup.icloud.com{icloud_china}/setup/ws/1" def _setup_cookie_directory(self, cookie_directory: Optional[str]) -> str: """Set up the cookie directory for the service.""" _cookie_directory: str = "" if cookie_directory: _cookie_directory = path.expanduser(path.normpath(cookie_directory)) if not path.exists(_cookie_directory): mkdir(_cookie_directory, 0o700) else: topdir: str = path.join(gettempdir(), "pyicloud") _cookie_directory = path.join(topdir, getpass.getuser()) if not path.exists(topdir): mkdir(topdir, 0o777) if not path.exists(_cookie_directory): mkdir(_cookie_directory, 0o700) return _cookie_directory def __init__( self, apple_id: str, password: Optional[str] = None, cookie_directory: Optional[str] = None, verify: bool = True, client_id: Optional[str] = None, with_family: bool = True, china_mainland: bool = False, ) -> None: self._is_china_mainland: bool = ( china_mainland or environ.get("icloud_china", "0") == "1" ) self._setup_endpoints() self._password: Optional[str] = password self._apple_id: str = apple_id if self._password is None: self._password = get_password_from_keyring(apple_id) self.data: dict[str, Any] = {} self.params: dict[str, Any] = {} self._client_id: str = client_id or (f"auth-{str(uuid1()).lower()}") self._with_family: bool = with_family self._password_filter: Optional[PyiCloudPasswordFilter] = None _cookie_directory = self._setup_cookie_directory(cookie_directory) self.session: PyiCloudSession = PyiCloudSession( self, verify=verify, headers={ "Origin": self.home_endpoint, "Referer": f"{self.home_endpoint}/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15", }, client_id=self._client_id, cookie_directory=_cookie_directory, ) self._client_id = self.session.data.get("client_id", self._client_id) self.params = { "clientBuildNumber": "2512Hotfix21", "clientMasteringNumber": "2512Hotfix21", "ckjsBuildVersion": "2310ProjectDev27", "ckjsVersion": "2.6.4", "clientId": self._client_id, } self._webservices: Optional[dict[str, dict[str, Any]]] = None self._account: Optional[AccountService] = None self._calendar: Optional[CalendarService] = None self._contacts: Optional[ContactsService] = None self._devices: Optional[FindMyiPhoneServiceManager] = None self._drive: Optional[DriveService] = None self._files: Optional[UbiquityService] = None self._hidemyemail: Optional[HideMyEmailService] = None self._photos: Optional[PhotosService] = None self._reminders: Optional[RemindersService] = None self.authenticate() def authenticate( self, force_refresh: bool = False, service: Optional[str] = None ) -> None: """ Handles authentication, and persists cookies so that subsequent logins will not cause additional e-mails from Apple. """ login_successful = False if self.session.data.get("session_token") and not force_refresh: try: self.data = self._validate_token() login_successful = True except PyiCloudAPIResponseException: LOGGER.debug("Invalid authentication token, will log in from scratch.") if ( not login_successful and service is not None and service in self.data["apps"] ): app: dict[str, Any] = self.data["apps"][service] if "canLaunchWithOneFactor" in app and app["canLaunchWithOneFactor"]: LOGGER.debug("Authenticating as %s for %s", self.account_name, service) try: self._authenticate_with_credentials_service(service) login_successful = True except Exception: LOGGER.debug( "Could not log into service. Attempting brand new login." ) if not login_successful: try: self._authenticate() except PyiCloud2FARequiredException: LOGGER.debug("2FA is required") self._update_state() def _update_state(self) -> None: """Update the state of the service.""" if ( "dsInfo" in self.data and isinstance(self.data["dsInfo"], dict) and "dsid" in self.data["dsInfo"] ): self.params.update({"dsid": self.data["dsInfo"]["dsid"]}) if "webservices" in self.data: self._webservices = self.data["webservices"] LOGGER.debug("Authentication completed successfully") def _authenticate(self) -> None: LOGGER.debug("Authenticating as %s", self.account_name) try: self._authenticate_with_token() except (PyiCloudFailedLoginException, PyiCloud2FARequiredException): headers: dict[str, str] = self._get_auth_headers() self._srp_authentication(headers) self._authenticate_with_token() def _srp_authentication(self, headers: dict[str, Any]) -> None: """SRP authentication.""" try: srp_password = SrpPassword(self.password) except PyiCloudPasswordException as error: raise PyiCloudFailedLoginException("Password not provided") from error srp.rfc5054_enable() srp.no_username_in_x() usr = srp.User( self.account_name, srp_password, hash_alg=srp.SHA256, ng_type=srp.NG_2048, ) uname, A = usr.start_authentication() # pylint: disable=invalid-name data: dict[str, Any] = { "a": b64_encode(A), ACCOUNT_NAME: uname, "protocols": ["s2k", "s2k_fo"], } try: response: Response = self.session.post( f"{self.auth_endpoint}/signin/init", json=data, headers=headers, ) response.raise_for_status() except (PyiCloudAPIResponseException, HTTPError) as error: msg = "Failed to initiate srp authentication." raise PyiCloudFailedLoginException(msg, error) from error body = response.json() salt: bytes = base64.b64decode(body["salt"]) b: bytes = base64.b64decode(body["b"]) c = body["c"] iterations: int = body["iteration"] key_length: int = 32 srp_password.set_encrypt_info(salt, iterations, key_length) m1 = usr.process_challenge(salt, b) m2 = usr.H_AMK if m1 and m2: data = { ACCOUNT_NAME: uname, "c": c, "m1": b64_encode(m1), "m2": b64_encode(m2), "rememberMe": True, "trustTokens": [], } if self.session.data.get("trust_token"): data["trustTokens"] = [self.session.data.get("trust_token")] try: self.session.post( f"{self.auth_endpoint}/signin/complete", params={ "isRememberMeEnabled": "true", }, json=data, headers=headers, ) except PyiCloud2FARequiredException: LOGGER.debug("2FA required to complete authentication.") except PyiCloudAPIResponseException as error: msg = "Invalid email/password combination." raise PyiCloudFailedLoginException(msg) from error def _authenticate_with_token(self) -> None: """Authenticate using session token.""" if not self.session.data.get("session_token"): raise PyiCloudFailedLoginException("No session token available") try: data: dict[str, Any] = { "accountCountryCode": self.session.data.get("account_country"), "dsWebAuthToken": self.session.data.get("session_token"), "extended_login": True, "trustToken": self.session.data.get("trust_token", ""), } resp: Response = self.session.post( f"{self.setup_endpoint}/accountLogin", json=data ) resp.raise_for_status() self.data = resp.json() if not self.is_trusted_session: raise PyiCloud2FARequiredException(self.account_name, resp) except (PyiCloudAPIResponseException, HTTPError) as error: msg = "Invalid authentication token." raise PyiCloudFailedLoginException(msg, error) from error def _authenticate_with_credentials_service(self, service: Optional[str]) -> None: """Authenticate to a specific service using credentials.""" data: dict[str, Any] = { "appName": service, "apple_id": self.account_name, "password": self.password, } try: self.session.post(f"{self.setup_endpoint}/accountLogin", json=data) self.data = self._validate_token() except PyiCloudAPIResponseException as error: msg = "Invalid email/password combination." raise PyiCloudFailedLoginException(msg, error) from error def _validate_token(self) -> Any: """Checks if the current access token is still valid.""" LOGGER.debug("Checking session token validity") if not self.session.cookies.get("X-APPLE-WEBAUTH-TOKEN"): raise PyiCloudAPIResponseException( "Missing X-APPLE-WEBAUTH-TOKEN cookie", None ) try: req: Response = self.session.post( f"{self.setup_endpoint}/validate", data="null" ) LOGGER.debug("Session token is still valid") return req.json() except PyiCloudAPIResponseException: LOGGER.debug("Invalid authentication token") raise def _get_auth_headers( self, overrides: Optional[dict[str, Any]] = None ) -> dict[str, Any]: headers: dict[str, Any] = { "Accept": f"{CONTENT_TYPE_JSON}, text/javascript", "Content-Type": CONTENT_TYPE_JSON, "X-Apple-OAuth-Client-Id": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", "X-Apple-OAuth-Client-Type": "firstPartyAuth", "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", "X-Apple-OAuth-Require-Grant-Code": "true", "X-Apple-OAuth-Response-Mode": "web_message", "X-Apple-OAuth-Response-Type": "code", "X-Apple-OAuth-State": self._client_id, "X-Apple-Widget-Key": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", } if self.session.data.get("scnt"): headers["scnt"] = self.session.data["scnt"] if self.session.data.get("session_id"): headers["X-Apple-ID-Session-Id"] = self.session.data["session_id"] if overrides: headers.update(overrides) return headers @property def requires_2sa(self) -> bool: """Returns True if two-step authentication is required.""" return self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 and ( self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session ) @property def requires_2fa(self) -> bool: """Returns True if two-factor authentication is required.""" return self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2 and ( self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session ) @property def is_trusted_session(self) -> bool: """Returns True if the session is trusted.""" return self.data.get("hsaTrustedBrowser", False) @property def trusted_devices(self) -> list[dict[str, Any]]: """Returns devices trusted for two-step authentication.""" request: Response = self.session.get( f"{self.setup_endpoint}/listDevices", params=self.params ) return request.json().get("devices") def send_verification_code(self, device: dict[str, Any]) -> bool: """Requests that a verification code is sent to the given device.""" request: Response = self.session.post( f"{self.setup_endpoint}/sendVerificationCode", params=self.params, json=device, ) return request.json().get("success", False) def validate_verification_code(self, device: dict[str, Any], code: str) -> bool: """Verifies a verification code received on a trusted device.""" device.update({"verificationCode": code, "trustBrowser": True}) try: self.session.post( f"{self.setup_endpoint}/validateVerificationCode", params=self.params, json=device, ) except PyiCloudAPIResponseException as error: if error.code == -21669: # Wrong verification code return False raise self.trust_session() return not self.requires_2sa def _get_webauthn_options(self) -> Dict: """Retrieve WebAuthn request options (PublicKeyCredentialRequestOptions) for assertion.""" headers = self._get_auth_headers({"Accept": CONTENT_TYPE_JSON}) return self.session.get(self.auth_endpoint, headers=headers).json() @property def security_key_names(self) -> Optional[List[str]]: """Security key names which can be used for the WebAuthn assertion.""" return self._get_webauthn_options().get("keyNames") def _submit_webauthn_assertion_response(self, data: Dict) -> None: """Submit the WebAuthn assertion response for authentication.""" headers = self._get_auth_headers({"Accept": CONTENT_TYPE_JSON}) self.session.post( f"{self.auth_endpoint}/verify/security/key", json=data, headers=headers ) @property def fido2_devices(self) -> List[CtapHidDevice]: """List the available FIDO2 devices.""" return list(CtapHidDevice.list_devices()) def confirm_security_key(self, device: Optional[CtapHidDevice] = None) -> None: """Conduct the WebAuthn assertion ceremony with user's FIDO2 device.""" options = self._get_webauthn_options() challenge = options["fsaChallenge"]["challenge"] allowed_credentials = options["fsaChallenge"]["keyHandles"] rp_id = options["fsaChallenge"]["rpId"] if not device: devices: List[CtapHidDevice] = list(CtapHidDevice.list_devices()) if not devices: raise RuntimeError("No FIDO2 devices found") device = devices[0] client = Fido2Client( device=device, origin="https://apple.com", ) credentials: List[PublicKeyCredentialDescriptor] = [ PublicKeyCredentialDescriptor( id=b64url_decode(cred_id), type=PublicKeyCredentialType("public-key") ) for cred_id in allowed_credentials ] assertion_options = PublicKeyCredentialRequestOptions( challenge=b64url_decode(challenge), rp_id=rp_id, allow_credentials=credentials, ) response: AuthenticatorAssertionResponse = client.get_assertion( assertion_options ).get_response(0) self._submit_webauthn_assertion_response( { "challenge": challenge, "clientData": b64_encode(response.client_data), "signatureData": b64_encode(response.signature), "authenticatorData": b64_encode(response.authenticator_data), "userHandle": b64_encode(response.user_handle) if response.user_handle else None, "credentialID": b64_encode(response.credential_id) if response.credential_id else None, "rpId": rp_id, } ) self.trust_session() def validate_2fa_code(self, code: str) -> bool: """Verifies a verification code received via Apple's 2FA system (HSA2).""" data: dict[str, Any] = {"securityCode": {"code": code}} headers: dict[str, Any] = self._get_auth_headers({"Accept": CONTENT_TYPE_JSON}) try: self.session.post( f"{self.auth_endpoint}/verify/trusteddevice/securitycode", json=data, headers=headers, ) except PyiCloudAPIResponseException: # Wrong verification code LOGGER.error("Code verification failed.") return False LOGGER.debug("Code verification successful.") self.trust_session() return not self.requires_2sa def trust_session(self) -> bool: """Request session trust to avoid user log in going forward.""" headers: dict[str, Any] = self._get_auth_headers() try: self.session.get( f"{self.auth_endpoint}/2sv/trust", headers=headers, ) self._authenticate_with_token() return True except (PyiCloudAPIResponseException, PyiCloud2FARequiredException): LOGGER.error("Session trust failed.") return False def get_webservice_url(self, ws_key: str) -> str: """Get webservice URL, raise an exception if not exists.""" if self._webservices is None or self._webservices.get(ws_key) is None: raise PyiCloudServiceNotActivatedException( f"Webservice not available: {ws_key}" ) return self._webservices[ws_key]["url"] @property def devices(self) -> FindMyiPhoneServiceManager: """Returns all devices.""" if not self._devices: try: service_root: str = self.get_webservice_url("findme") self._devices = FindMyiPhoneServiceManager( service_root, self.session, self.params, self._with_family ) except PyiCloudServiceNotActivatedException as error: raise PyiCloudServiceUnavailable( "Find My iPhone service not available" ) from error return self._devices @property def hidemyemail(self) -> HideMyEmailService: """Gets the 'HME' service.""" if not self._hidemyemail: service_root: str = self.get_webservice_url("premiummailsettings") try: self._hidemyemail = HideMyEmailService( service_root, self.session, self.params ) except PyiCloudAPIResponseException as error: raise PyiCloudServiceUnavailable( "Hide My Email service not available" ) from error return self._hidemyemail @property def iphone(self) -> AppleDevice: """Returns the iPhone.""" return self.devices[0] @property def account(self) -> AccountService: """Gets the 'Account' service.""" if not self._account: service_root: str = self.get_webservice_url("account") try: self._account = AccountService( service_root=service_root, session=self.session, china_mainland=self._is_china_mainland, params=self.params, ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Account service not available" ) from error return self._account @property def files(self) -> UbiquityService: """Gets the 'File' service.""" if not self._files: service_root: str = self.get_webservice_url("ubiquity") try: self._files = UbiquityService(service_root, self.session, self.params) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Files service not available" ) from error return self._files @property def photos(self) -> PhotosService: """Gets the 'Photo' service.""" if not self._photos: service_root: str = self.get_webservice_url("ckdatabasews") upload_url: str = self.get_webservice_url("uploadimagews") shared_streams_url: str = self.get_webservice_url("sharedstreams") self.params["dsid"] = self.data["dsInfo"]["dsid"] try: self._photos = PhotosService( service_root, self.session, self.params, upload_url, shared_streams_url, ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Photos service not available" ) from error return self._photos @property def calendar(self) -> CalendarService: """Gets the 'Calendar' service.""" if not self._calendar: service_root: str = self.get_webservice_url("calendar") try: self._calendar = CalendarService( service_root, self.session, self.params ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Calendar service not available" ) from error return self._calendar @property def contacts(self) -> ContactsService: """Gets the 'Contacts' service.""" if not self._contacts: service_root: str = self.get_webservice_url("contacts") try: self._contacts = ContactsService( service_root, self.session, self.params ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Contacts service not available" ) from error return self._contacts @property def reminders(self) -> RemindersService: """Gets the 'Reminders' service.""" if not self._reminders: service_root: str = self.get_webservice_url("reminders") try: self._reminders = RemindersService( service_root, self.session, self.params ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Reminders service not available" ) from error return self._reminders @property def drive(self) -> DriveService: """Gets the 'Drive' service.""" if not self._drive: try: self._drive = DriveService( service_root=self.get_webservice_url("drivews"), document_root=self.get_webservice_url("docws"), session=self.session, params=self.params, ) except (PyiCloudAPIResponseException,) as error: raise PyiCloudServiceUnavailable( "Drive service not available" ) from error return self._drive @property def account_name(self) -> str: """Retrieves the account name associated with the Apple ID.""" return self._apple_id @property def password(self) -> str: """Retrieves the password associated with the Apple ID.""" if self._password is None: raise PyiCloudPasswordException() self._password_filter = PyiCloudPasswordFilter(self._password) LOGGER.addFilter(self._password_filter) return self._password @property def password_filter(self) -> Optional[PyiCloudPasswordFilter]: """Retrieves the password filter""" return self._password_filter if self._password_filter else None def __str__(self) -> str: return f"iCloud API: {self.account_name}" def __repr__(self) -> str: return f"<{self}>" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/cmdline.py0000644000175100001660000003125615023360704016571 0ustar00runnerdocker#! /usr/bin/env python """ A Command Line Wrapper to allow easy use of pyicloud for command line scripts, and related. """ import argparse import logging import pickle import sys from typing import Any, Optional from click import confirm from pyicloud import PyiCloudService, utils from pyicloud.exceptions import PyiCloudFailedLoginException from pyicloud.services.findmyiphone import AppleDevice 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.content, 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.", ) parser.add_argument( "--list", action="store_true", dest="list", default=False, help="Short Listings for Device(s) associated with account", ) parser.add_argument( "--llist", action="store_true", dest="longlist", default=False, help="Detailed Listings for Device(s) associated with account", ) parser.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", ) 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) failure_count = 0 while True: password: Optional[str] = _get_password(username, parser, command_line) api: Optional[PyiCloudService] = _authenticate( username, password, china_mainland, parser, command_line, failures=failure_count, ) if not api: failure_count += 1 else: break _print_devices(api, command_line) def _authenticate( username: str, password: Optional[str], china_mainland: bool, parser: argparse.ArgumentParser, command_line: argparse.Namespace, failures: int = 0, ) -> Optional[PyiCloudService]: api = None try: api = PyiCloudService(username, password, china_mainland=china_mainland) 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}" failures += 1 if failures >= 3: raise RuntimeError(message) from err print(message, file=sys.stderr) def _print_devices(api: PyiCloudService, command_line: argparse.Namespace) -> None: for dev in api.devices: if not command_line.device_id or ( command_line.device_id.strip().lower() == dev.content["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) 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: if command_line.locate: dev.location() if command_line.output_to_file: create_pickled_data( dev, filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"), ) contents: dict[str, Any] = dev.content if command_line.longlist: print("-" * 30) print(contents["name"]) for key in contents: print(f"{key:>20} - {contents[key]}") elif command_line.list: print("-" * 30) print(f"Name - {contents['name']}") print(f"Display Name - {contents['deviceDisplayName']}") print(f"Location - {contents['location']}") print(f"Battery Level - {contents['batteryLevel']}") print(f"Battery Status - {contents['batteryStatus']}") print(f"Device Class - {contents['deviceClass']}") print(f"Device Model - {contents['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"SMS to {device.get('phoneNumber')}" print(f" {i}: {device.get('deviceName', phone_number)}") return devices if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/const.py0000644000175100001660000000122215023360704016272 0ustar00runnerdocker"""Contants for the PyiCloud API.""" CONTENT_TYPE = "Content-Type" CONTENT_TYPE_JSON = "application/json" CONTENT_TYPE_TEXT = "plain/text" CONTENT_TYPE_TEXT_JSON = "text/json" HEADER_DATA: dict[str, str] = { "X-Apple-ID-Account-Country": "account_country", "X-Apple-ID-Session-Id": "session_id", "X-Apple-Session-Token": "session_token", "X-Apple-TwoSV-Trust-Token": "trust_token", "X-Apple-I-Rscd": "apple_rscd", "X-Apple-I-Ercd": "apple_ercd", "scnt": "scnt", } ACCOUNT_NAME = "accountName" ERROR_ACCESS_DENIED = "ACCESS_DENIED" ERROR_ZONE_NOT_FOUND = "ZONE_NOT_FOUND" ERROR_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/exceptions.py0000644000175100001660000000365215023360704017336 0ustar00runnerdocker"""Library exceptions.""" from typing import Optional, Union from requests import Response class PyiCloudException(Exception): """Generic iCloud exception.""" class PyiCloudPasswordException(PyiCloudException): """Password exception.""" class PyiCloudServiceUnavailable(PyiCloudException): """Service unavailable exception.""" class TokenException(PyiCloudException): """Token exception.""" # API class PyiCloudAPIResponseException(PyiCloudException): """iCloud response exception.""" def __init__( self, reason: str, code: Optional[Union[int, str]] = None, retry: bool = False ) -> None: self.reason: str = reason self.code: Optional[Union[int, str]] = code message: str = reason or "" if code: message += f" ({code})" if retry: message += ". Retrying ..." super().__init__(message) class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException): """iCloud service not activated exception.""" # Login class PyiCloudFailedLoginException(PyiCloudException): """iCloud failed login exception.""" class PyiCloud2FARequiredException(PyiCloudException): """iCloud 2FA required exception.""" def __init__(self, apple_id: str, response: Response) -> None: message: str = f"2FA authentication required for account: {apple_id} (HSA2)" super().__init__(message) self.response: Response = response class PyiCloud2SARequiredException(PyiCloudException): """iCloud 2SA required exception.""" def __init__(self, apple_id: str) -> None: message: str = f"Two-step authentication required for account: {apple_id}" super().__init__(message) class PyiCloudNoStoredPasswordAvailableException(PyiCloudException): """iCloud no stored password exception.""" # Webservice specific class PyiCloudNoDevicesException(PyiCloudException): """iCloud no device exception.""" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1749934535.612579 pyicloud-2.0.1/pyicloud/services/0000755000175100001660000000000015023360710016415 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/__init__.py0000644000175100001660000000143515023360704020534 0ustar00runnerdocker"""Services.""" from pyicloud.services.account import AccountService from pyicloud.services.calendar import CalendarService from pyicloud.services.contacts import ContactsService from pyicloud.services.drive import DriveService from pyicloud.services.findmyiphone import AppleDevice, FindMyiPhoneServiceManager from pyicloud.services.hidemyemail import HideMyEmailService from pyicloud.services.photos import PhotosService from pyicloud.services.reminders import RemindersService from pyicloud.services.ubiquity import UbiquityService __all__: list[str] = [ "AppleDevice", "AccountService", "CalendarService", "ContactsService", "DriveService", "FindMyiPhoneServiceManager", "HideMyEmailService", "PhotosService", "RemindersService", "UbiquityService", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/account.py0000644000175100001660000002611015023360704020426 0ustar00runnerdocker"""Account service.""" from typing import Any, Optional from requests import Response from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession from pyicloud.utils import underscore_to_camelcase DEFAULT_DSID = "20288408776" class AccountService(BaseService): """The 'Account' iCloud service.""" def __init__( self, service_root: str, session: PyiCloudSession, china_mainland: bool, params: dict[str, Any], ) -> None: super().__init__(service_root, session, params) self._devices: list["AccountDevice"] = [] self._family: list["FamilyMember"] = [] self._storage: Optional[AccountStorage] = None self._acc_endpoint: str = f"{self.service_root}/setup/web" self._acc_devices_url: str = f"{self._acc_endpoint}/device/getDevices" self._acc_family_details_url: str = ( f"{self._acc_endpoint}/family/getFamilyDetails" ) self._acc_family_member_photo_url: str = ( f"{self._acc_endpoint}/family/getMemberPhoto" ) self._acc_storage_url: str = f"{self.service_root}/setup/ws/1/storageUsageInfo" self._gateway: str = ( f"https://gatewayws.icloud.com{'' if not china_mainland else '.cn'}" ) self._gateway_root: str = f"{self._gateway}/acsegateway" dsid: str = self.params.get("dsid", DEFAULT_DSID) self._gateway_pricing_url: str = ( f"{self._gateway_root}/v1/accounts/{dsid}/plans/icloud/pricing" ) self._gateway_summary_plan_url: str = ( f"{self._gateway_root}/v3/accounts/{dsid}/subscriptions" "/features/cloud.storage/plan-summary" ) @property def devices(self) -> list["AccountDevice"]: """Returns current paired devices.""" if not self._devices: req: Response = self.session.get(self._acc_devices_url, params=self.params) response = req.json() for device_info in response["devices"]: self._devices.append(AccountDevice(device_info)) return self._devices @property def family(self) -> list["FamilyMember"]: """Returns family members.""" if not self._family: req: Response = self.session.get( self._acc_family_details_url, params=self.params ) response = req.json() for member_info in response["familyMembers"]: self._family.append( FamilyMember( member_info, self.session, self.params, self._acc_family_member_photo_url, ) ) return self._family @property def storage(self) -> "AccountStorage": """Returns storage infos.""" if not self._storage: req: Response = self.session.post(self._acc_storage_url, params=self.params) response = req.json() self._storage = AccountStorage(response) return self._storage @property def summary_plan(self): """Returns your subscription plan.""" req: Response = self.session.get( self._gateway_summary_plan_url, params=self.params ) response = req.json() return response def __str__(self) -> str: return "{{devices: {}, family: {}, storage: {} bytes free}}".format( len(self.devices), len(self.family), self.storage.usage.available_storage_in_bytes, ) def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" class AccountDevice(dict): """Account device.""" def __getattr__(self, key: str): return self[underscore_to_camelcase(key)] def __str__(self) -> str: return f"{{model: {self.model_display_name}, name: {self.name}}}" def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" class FamilyMember: """A family member.""" def __init__( self, member_info: dict[str, Any], session: PyiCloudSession, params: dict[str, Any], acc_family_member_photo_url: str, ) -> None: self._attrs: dict[str, Any] = member_info self._session: PyiCloudSession = session self._params: dict[str, Any] = params self._acc_family_member_photo_url: str = acc_family_member_photo_url @property def last_name(self) -> Optional[str]: """Gets the last name.""" return self._attrs.get("lastName") @property def dsid(self) -> Optional[str]: """Gets the dsid.""" return self._attrs.get("dsid") @property def original_invitation_email(self) -> Optional[str]: """Gets the original invitation.""" return self._attrs.get("originalInvitationEmail") @property def full_name(self) -> Optional[str]: """Gets the full name.""" return self._attrs.get("fullName") @property def age_classification(self): """Gets the age classification.""" return self._attrs.get("ageClassification") @property def apple_id_for_purchases(self) -> Optional[str]: """Gets the apple id for purchases.""" return self._attrs.get("appleIdForPurchases") @property def apple_id(self) -> Optional[str]: """Gets the apple id.""" return self._attrs.get("appleId") @property def family_id(self): """Gets the family id.""" return self._attrs.get("familyId") @property def first_name(self) -> Optional[str]: """Gets the first name.""" return self._attrs.get("firstName") @property def has_parental_privileges(self): """Has parental privileges.""" return self._attrs.get("hasParentalPrivileges") @property def has_screen_time_enabled(self): """Has screen time enabled.""" return self._attrs.get("hasScreenTimeEnabled") @property def has_ask_to_buy_enabled(self): """Has to ask for buying.""" return self._attrs.get("hasAskToBuyEnabled") @property def has_share_purchases_enabled(self): """Has share purshases.""" return self._attrs.get("hasSharePurchasesEnabled") @property def share_my_location_enabled_family_members(self): """Has share my location with family.""" return self._attrs.get("shareMyLocationEnabledFamilyMembers") @property def has_share_my_location_enabled(self): """Has share my location.""" return self._attrs.get("hasShareMyLocationEnabled") @property def dsid_for_purchases(self): """Gets the dsid for purchases.""" return self._attrs.get("dsidForPurchases") def get_photo(self) -> Response: """Returns the photo.""" params_photo = dict(self._params) params_photo.update({"memberId": self.dsid}) return self._session.get( self._acc_family_member_photo_url, params=params_photo, stream=True ) def __getitem__(self, key): if self._attrs.get(key): return self._attrs[key] return getattr(self, key) def __str__(self) -> str: return "{{name: {}, age_classification: {}}}".format( self.full_name, self.age_classification, ) def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" class AccountStorageUsageForMedia: """Storage used for a specific media type into the account.""" def __init__(self, usage_data) -> None: self.usage_data: dict[str, Any] = usage_data @property def key(self): """Gets the key.""" return self.usage_data["mediaKey"] @property def label(self): """Gets the label.""" return self.usage_data["displayLabel"] @property def color(self): """Gets the HEX color.""" return self.usage_data["displayColor"] @property def usage_in_bytes(self): """Gets the usage in bytes.""" return self.usage_data["usageInBytes"] def __str__(self) -> str: return f"{{key: {self.key}, usage: {self.usage_in_bytes} bytes}}" def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" class AccountStorageUsage: """Storage used for a specific media type into the account.""" def __init__(self, usage_data, quota_data) -> None: self.usage_data: dict[str, Any] = usage_data self.quota_data: dict[str, Any] = quota_data @property def comp_storage_in_bytes(self): """Gets the comp storage in bytes.""" return self.usage_data["compStorageInBytes"] @property def used_storage_in_bytes(self): """Gets the used storage in bytes.""" return self.usage_data["usedStorageInBytes"] @property def used_storage_in_percent(self): """Gets the used storage in percent.""" return round(self.used_storage_in_bytes * 100 / self.total_storage_in_bytes, 2) @property def available_storage_in_bytes(self): """Gets the available storage in bytes.""" return self.total_storage_in_bytes - self.used_storage_in_bytes @property def available_storage_in_percent(self): """Gets the available storage in percent.""" return round( self.available_storage_in_bytes * 100 / self.total_storage_in_bytes, 2 ) @property def total_storage_in_bytes(self): """Gets the total storage in bytes.""" return self.usage_data["totalStorageInBytes"] @property def commerce_storage_in_bytes(self): """Gets the commerce storage in bytes.""" return self.usage_data["commerceStorageInBytes"] @property def quota_over(self): """Gets the over quota.""" return self.quota_data["overQuota"] @property def quota_tier_max(self): """Gets the max tier quota.""" return self.quota_data["haveMaxQuotaTier"] @property def quota_almost_full(self): """Gets the almost full quota.""" return self.quota_data["almost-full"] @property def quota_paid(self): """Gets the paid quota.""" return self.quota_data["paidQuota"] def __str__(self) -> str: return "{}% used of {} bytes".format( self.used_storage_in_percent, self.total_storage_in_bytes, ) def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" class AccountStorage: """Storage of the account.""" def __init__(self, storage_data) -> None: self.usage = AccountStorageUsage( storage_data.get("storageUsageInfo"), storage_data.get("quotaStatus") ) self.usages_by_media: dict[str, AccountStorageUsageForMedia] = {} for usage_media in storage_data.get("storageUsageByMedia"): self.usages_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia( usage_media ) def __str__(self) -> str: return f"{{usage: {self.usage}, usages_by_media: {self.usages_by_media}}}" def __repr__(self) -> str: return f"<{type(self).__name__}: {self}>" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/base.py0000644000175100001660000000143415023360704017706 0ustar00runnerdocker"""Base service.""" from abc import ABC from typing import Any from pyicloud.session import PyiCloudSession class BaseService(ABC): """The base iCloud service.""" def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: self.__session: PyiCloudSession = session self.__params: dict[str, Any] = params self.__service_root: str = service_root @property def session(self) -> PyiCloudSession: """The session object.""" return self.__session @property def params(self) -> dict[str, Any]: """The request parameters.""" return self.__params @property def service_root(self) -> str: """The service root URL.""" return self.__service_root ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/calendar.py0000644000175100001660000003436115023360704020552 0ustar00runnerdocker"""Calendar service.""" import json from calendar import monthrange from dataclasses import asdict, dataclass, field from datetime import datetime, timedelta from random import randint from typing import Any, List, Literal, Optional, TypeVar, Union, cast, overload from uuid import uuid4 from requests import Response from tzlocal import get_localzone_name from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession T = TypeVar("T") @dataclass class EventObject: """ An EventObject represents an event in the Apple Calendar. """ pguid: str title: str = "New Event" start_date: datetime = datetime.today() end_date: datetime = datetime.today() + timedelta(minutes=60) local_start_date: Optional[datetime] = None local_end_date: Optional[datetime] = None duration: int = field(init=False) icon: int = 0 change_recurring: Optional[str] = None tz: str = "US/Pacific" guid: str = "" # event identifier location: str = "" extended_details_are_included: bool = True recurrence_exception: bool = False recurrence_master: bool = False has_attachments: bool = False all_day: bool = False is_junk: bool = False etag: Optional[str] = None invitees: List[str] = field(init=False, default_factory=list) def __post_init__(self) -> None: if not self.local_start_date: self.local_start_date = self.start_date if not self.local_end_date: self.local_end_date = self.end_date if not self.guid: self.guid = str(uuid4()).upper() self.duration = int( (self.end_date.timestamp() - self.start_date.timestamp()) / 60 ) @property def request_data(self) -> dict[str, Any]: """ Returns the event data in the format required by Apple's calendar. """ event_dict: dict[str, Any] = asdict(self) event_dict["startDate"] = self.dt_to_list(self.start_date) event_dict["endDate"] = self.dt_to_list(self.end_date, False) if self.local_start_date: event_dict["localStartDate"] = self.dt_to_list(self.local_start_date) if self.local_end_date: event_dict["localEndDate"] = self.dt_to_list(self.local_end_date, False) event_dict.pop("start_date", None) event_dict.pop("end_date", None) event_dict.pop("local_start_date", None) event_dict.pop("local_end_date", None) data: dict[str, Any] = { "Event": event_dict, "ClientState": { "Collection": [{"guid": self.guid, "ctag": None}], "fullState": False, "userTime": 1234567890, "alarmRange": 60, }, } if self.invitees: data["Invitee"] = [ { "guid": email_guid, "pGuid": self.pguid, "role": "REQ-PARTICIPANT", "isOrganizer": False, "email": email_guid.split(":")[-1], "inviteeStatus": "NEEDS-ACTION", "commonName": "", "isMyId": False, } for email_guid in self.invitees ] return data def dt_to_list(self, dt: datetime, start: bool = True) -> list: """ Converts python datetime object into a list format used by Apple's calendar. """ if start: minutes: int = dt.hour * 60 + dt.minute else: minutes = (24 - dt.hour) * 60 + (60 - dt.minute) return [ dt.strftime("%Y%m%d"), dt.year, dt.month, dt.day, dt.hour, dt.minute, minutes, ] def add_invitees(self, _invitees: Optional[list] = None) -> None: """ Adds a list of emails to invitees in the correct format """ if _invitees: self.invitees += ["{}:{}".format(self.guid, email) for email in _invitees] def get(self, var: str): """Get a variable""" return getattr(self, var, None) @dataclass class CalendarObject: """ A CalendarObject represents a calendar in the Apple Calendar. """ title: str = "Untitled" guid: str = "" share_type: Optional[str] = ( None # can be (None, 'published', 'shared') where 'published' gens a public caldav link in the response. Shared is not supported here as it is rather complex. ) symbolic_color: str = "__custom__" supported_type: str = "Event" object_type: str = "personal" share_title: str = "" shared_url: str = "" color: str = "" order: int = 7 extended_details_are_included: bool = True read_only: bool = False enabled: bool = True ignore_event_updates: Optional[str] = None email_notification: Optional[str] = None last_modified_date: Optional[str] = None me_as_participant: Optional[str] = None pre_published_url: Optional[str] = None participants: Optional[str] = None defer_loading: Optional[str] = None published_url: Optional[str] = None remove_alarms: Optional[str] = None ignore_alarms: Optional[str] = None description: Optional[str] = None remove_todos: Optional[str] = None is_default: Optional[bool] = None is_family: Optional[bool] = None etag: Optional[str] = None ctag: Optional[str] = None def __post_init__(self) -> None: if not self.guid: self.guid = str(uuid4()).upper() if not self.color: self.color = self.gen_random_color() def gen_random_color(self) -> str: """ Creates a random rgbhex color. """ return "#%02x%02x%02x" % tuple([randint(0, 255) for _ in range(3)]) @property def request_data(self) -> dict[str, Any]: """Returns the calendar data in the format required by Apple's calendar.""" data: dict[str, Any] = { "Collection": asdict(self), "ClientState": { "Collection": [], "fullState": False, "userTime": 1234567890, "alarmRange": 60, }, } return data class CalendarService(BaseService): """ The 'Calendar' iCloud service, connects to iCloud and returns events. """ def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: super().__init__(service_root, session, params) self._calendar_endpoint: str = f"{self.service_root}/ca" self._calendar_refresh_url: str = f"{self._calendar_endpoint}/events" self._calendar_event_detail_url: str = f"{self._calendar_endpoint}/eventdetail" self._calendar_collections_url: str = f"{self._calendar_endpoint}/collections" self._calendars_url: str = f"{self._calendar_endpoint}/allcollections" @property def default_params(self) -> dict[str, Any]: """Returns the default parameters for the calendar service.""" today: datetime = datetime.today() first_day, last_day = monthrange(today.year, today.month) from_dt = datetime(today.year, today.month, first_day) to_dt = datetime(today.year, today.month, last_day) params = dict(self.params) params.update( { "lang": "en-us", "usertz": get_localzone_name(), "startDate": from_dt.strftime("%Y-%m-%d"), "endDate": to_dt.strftime("%Y-%m-%d"), } ) return params def obj_from_dict(self, obj: T, _dict) -> T: """Creates an object from a dictionary""" for key, value in _dict.items(): setattr(obj, key, value) return obj def get_ctag(self, guid: str) -> str: """Returns the ctag for a given calendar guid""" ctag: Optional[str] = None for cal in self.get_calendars(as_objs=False): if isinstance(cal, CalendarObject) and cal.guid == guid: ctag = cal.ctag elif isinstance(cal, dict) and cal.get("guid") == guid: ctag = cal.get("ctag") if ctag: return ctag raise ValueError("ctag not found.") def refresh_client(self, from_dt=None, to_dt=None) -> dict[str, Any]: """ Refreshes the CalendarService endpoint, ensuring that the event data is up-to-date. If no 'from_dt' or 'to_dt' datetimes have been given, the range becomes this month. """ today: datetime = datetime.today() first_day, last_day = monthrange(today.year, today.month) if not from_dt: from_dt = datetime(today.year, today.month, first_day) if not to_dt: to_dt = datetime(today.year, today.month, last_day) params = dict(self.params) params.update( { "lang": "en-us", "usertz": get_localzone_name(), "startDate": from_dt.strftime("%Y-%m-%d"), "endDate": to_dt.strftime("%Y-%m-%d"), "dsid": self.session.service.data["dsInfo"]["dsid"], } ) req: Response = self.session.get(self._calendar_refresh_url, params=params) return req.json() @overload def get_calendars(self) -> list[dict[str, Any]]: ... @overload def get_calendars(self, as_objs: Literal[False]) -> list[dict[str, Any]]: ... @overload def get_calendars(self, as_objs: Literal[True]) -> list[CalendarObject]: ... def get_calendars( self, as_objs: Union[Literal[True], Literal[False]] = False ) -> Union[list[dict[str, Any]], list[CalendarObject]]: """ Retrieves calendars of this month. """ params: dict[str, Any] = self.default_params req: Response = self.session.get(self._calendars_url, params=params) response = req.json() calendars: list[dict[str, Any]] = response["Collection"] if not as_objs and calendars: return calendars return [self.obj_from_dict(CalendarObject(), cal) for cal in calendars] def add_calendar(self, calendar: CalendarObject) -> dict[str, Any]: """ Adds a Calendar to the apple calendar. """ data: dict[str, Any] = calendar.request_data params: dict[str, Any] = self.default_params req: Response = self.session.post( self._calendar_collections_url + f"/{calendar.guid}", params=params, data=json.dumps(data), ) return req.json() def remove_calendar(self, cal_guid: str) -> dict[str, Any]: """ Removes a Calendar from the apple calendar. """ params: dict[str, Any] = self.default_params params["methodOverride"] = "DELETE" req: Response = self.session.post( self._calendar_collections_url + f"/{cal_guid}", params=params, data=json.dumps({}), ) return req.json() def get_events( self, from_dt: Optional[datetime] = None, to_dt: Optional[datetime] = None, period: str = "month", as_objs: bool = False, ) -> list: """ Retrieves events for a given date range, by default, this month. """ today: datetime = datetime.today() if period != "month" and from_dt: today = datetime(from_dt.year, from_dt.month, from_dt.day) if period == "day": if not from_dt: from_dt = datetime(today.year, today.month, today.day) to_dt = from_dt + timedelta(days=1) elif period == "week": if not from_dt: from_dt = datetime(today.year, today.month, today.day) - timedelta( days=today.weekday() + 1 ) to_dt = from_dt + timedelta(days=6) response: dict[str, Any] = self.refresh_client(from_dt, to_dt) events: list = response.get("Event", []) if as_objs and events: for idx, event in enumerate(events): events[idx] = self.obj_from_dict(EventObject(""), event) return events def get_event_detail(self, pguid, guid, as_obj: bool = False) -> EventObject: """ Fetches a single event's details by specifying a pguid (a calendar) and a guid (an event's ID). """ params = dict(self.params) params.update( { "lang": "en-us", "usertz": get_localzone_name(), "dsid": self.session.service.data["dsInfo"]["dsid"], } ) url: str = f"{self._calendar_event_detail_url}/{pguid}/{guid}" req: Response = self.session.get(url, params=params) response = req.json() event = response["Event"][0] if as_obj and event: event: EventObject = cast( EventObject, self.obj_from_dict(EventObject(pguid=pguid), event), ) return event def add_event(self, event: EventObject) -> dict[str, Any]: """ Adds an Event to a calendar. """ data = event.request_data data["ClientState"]["Collection"][0]["ctag"] = self.get_ctag(event.guid) params = self.default_params req: Response = self.session.post( self._calendar_refresh_url + f"/{event.pguid}/{event.guid}", params=params, data=json.dumps(data), ) return req.json() def remove_event(self, event: EventObject) -> dict[str, Any]: """ Removes an Event from a calendar. The calendar's guid corresponds to the EventObject's pGuid """ data = event.request_data data["ClientState"]["Collection"][0]["ctag"] = self.get_ctag(event.guid) data["Event"] = {} params: dict[str, Any] = self.default_params params["methodOverride"] = "DELETE" if not getattr(event, "etag", None): event.etag = self.get_event_detail( event.pguid, event.guid, as_obj=False ).get("etag") params["ifMatch"] = event.etag req: Response = self.session.post( self._calendar_refresh_url + f"/{event.pguid}/{event.guid}", params=params, data=json.dumps(data), ) return req.json() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/contacts.py0000644000175100001660000000703215023360704020612 0ustar00runnerdocker"""Contacts service.""" from typing import Any, Optional from requests import Response from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession class ContactsService(BaseService): """ The 'Contacts' iCloud service, connects to iCloud and returns contacts. """ def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: super().__init__(service_root, session, params) self._contacts_endpoint: str = f"{self.service_root}/co" self._contacts_refresh_url: str = f"{self._contacts_endpoint}/startup" self._contacts_next_url: str = f"{self._contacts_endpoint}/contacts" self._contacts_changeset_url: str = f"{self._contacts_endpoint}/changeset" self._contacts_me_card_url: str = f"{self._contacts_endpoint}/mecard" self._contacts: Optional[list] = None def refresh_client(self) -> None: """ Refreshes the ContactsService endpoint, ensuring that the contacts data is up-to-date. """ params_contacts = dict(self.params) params_contacts.update( { "locale": "en_US", "order": "last,first", "includePhoneNumbers": True, "includePhotos": True, } ) req: Response = self.session.get( self._contacts_refresh_url, params=params_contacts ) response: dict[str, Any] = req.json() params_next = dict(params_contacts) params_next.update( { "prefToken": response["prefToken"], "syncToken": response["syncToken"], "limit": "0", "offset": "0", } ) req = self.session.get(self._contacts_next_url, params=params_next) response = req.json() self._contacts = response.get("contacts") @property def all(self): """ Retrieves all contacts. """ self.refresh_client() return self._contacts @property def me(self) -> "MeCard": """ Retrieves the user's own contact information. """ params_contacts = dict(self.params) req: Response = self.session.get( self._contacts_me_card_url, params=params_contacts ) response = req.json() return MeCard(response) class MeCard: """ The 'MeCard' class represents the user's own contact information. """ def __init__(self, data: dict[str, Any]) -> None: self._data: dict[str, Any] = data contacts = data.get("contacts") if isinstance(contacts, list) and isinstance(contacts[0], dict): self._contact: dict[str, Any] = contacts[0] else: raise KeyError("contacts not found in data") @property def first_name(self) -> str: """ The user's first name. """ return self._contact["firstName"] @property def last_name(self) -> str: """ The user's last name. """ return self._contact["lastName"] @property def photo(self): """ The user's photo. """ return self._contact["photo"] def __str__(self) -> str: return f"{self.first_name} {self.last_name}" def __repr__(self) -> str: return f"" @property def raw_data(self) -> dict[str, Any]: """ The raw data of the mecard. """ return self._data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/drive.py0000644000175100001660000004613315023360704020112 0ustar00runnerdocker"""Drive service.""" import io import json import logging import mimetypes import os import time import uuid from datetime import datetime, timedelta from re import Match, search from typing import IO, Any, Optional from requests import Response from pyicloud.const import CONTENT_TYPE, CONTENT_TYPE_TEXT from pyicloud.exceptions import PyiCloudAPIResponseException, TokenException from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession LOGGER: logging.Logger = logging.getLogger(__name__) COOKIE_APPLE_WEBAUTH_VALIDATE: str = "X-APPLE-WEBAUTH-VALIDATE" CLOUD_DOCS_ZONE: str = "com.apple.CloudDocs" NODE_ROOT: str = "root" NODE_TRASH: str = "TRASH_ROOT" CLOUD_DOCS_ZONE_ID_ROOT: str = f"FOLDER::{CLOUD_DOCS_ZONE}::{NODE_ROOT}" CLOUD_DOCS_ZONE_ID_TRASH: str = f"FOLDER::{CLOUD_DOCS_ZONE}::{NODE_TRASH}" class DriveService(BaseService): """The 'Drive' iCloud service.""" def __init__( self, service_root: str, document_root: str, session: PyiCloudSession, params: dict[str, Any], ) -> None: super().__init__(service_root, session, params) self._document_root: str = document_root self._root: Optional[DriveNode] = None self._trash: Optional[DriveNode] = None def _get_token_from_cookie(self) -> dict[str, Any]: for cookie in self.session.cookies: if cookie.name == COOKIE_APPLE_WEBAUTH_VALIDATE and cookie.value: match: Optional[Match[str]] = search(r"\bt=([^:]+)", cookie.value) if match is None: raise TokenException("Can't extract token from %r" % cookie.value) return {"token": match.group(1)} raise TokenException("Token cookie not found") def get_node_data(self, drivewsid): """Returns the node data.""" request: Response = self.session.post( self.service_root + "/retrieveItemDetailsInFolders", params=self.params, data=json.dumps( [ { "drivewsid": drivewsid, "partialData": False, } ] ), ) self._raise_if_error(request) return request.json()[0] def get_file(self, file_id: str, zone: str = CLOUD_DOCS_ZONE, **kwargs) -> Response: """Returns iCloud Drive file.""" file_params = dict(self.params) file_params.update({"document_id": file_id}) response: Response = self.session.get( self._document_root + f"/ws/{zone}/download/by_id", params=file_params, ) self._raise_if_error(response) response_json = response.json() package_token = response_json.get("package_token") data_token = response_json.get("data_token") if data_token and data_token.get("url"): return self.session.get(data_token["url"], params=self.params, **kwargs) if package_token and package_token.get("url"): return self.session.get(package_token["url"], params=self.params, **kwargs) raise KeyError("'data_token' nor 'package_token'") def get_app_data(self): """Returns the app library (previously ubiquity).""" request: Response = self.session.get( self.service_root + "/retrieveAppLibraries", params=self.params ) self._raise_if_error(request) return request.json()["items"] def _get_upload_contentws_url( self, file_object: IO, zone: str = CLOUD_DOCS_ZONE, ) -> tuple[str, str]: """Get the contentWS endpoint URL to add a new file.""" content_type: Optional[str] = mimetypes.guess_type(file_object.name)[0] if content_type is None: content_type = "" # Get filesize from file object orig_pos: int = file_object.tell() file_object.seek(0, os.SEEK_END) file_size: int = file_object.tell() file_object.seek(orig_pos, os.SEEK_SET) file_params: dict[str, Any] = self.params file_params.update(self._get_token_from_cookie()) request: Response = self.session.post( self._document_root + f"/ws/{zone}/upload/web", params=file_params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": file_object.name, "type": "FILE", "content_type": content_type, "size": file_size, } ), ) self._raise_if_error(request) return (request.json()[0]["document_id"], request.json()[0]["url"]) def _update_contentws( self, folder_id: str, file_info: dict[str, Any], document_id: str, file_object: IO, zone: str = CLOUD_DOCS_ZONE, **kwargs, ): data: dict[str, Any] = { "data": { "signature": file_info["fileChecksum"], "wrapping_key": file_info["wrappingKey"], "reference_signature": file_info["referenceChecksum"], "size": file_info["size"], }, "command": "add_file", "create_short_guid": True, "document_id": document_id, "path": { "starting_document_id": folder_id, "path": os.path.basename(file_object.name), }, "allow_conflict": True, "file_flags": { "is_writable": True, "is_executable": False, "is_hidden": False, }, "mtime": int(kwargs.get("mtime", time.time()) * 1000), "btime": int(kwargs.get("ctime", time.time()) * 1000), } # Add the receipt if we have one. Will be absent for 0-sized files if file_info.get("receipt"): data["data"].update({"receipt": file_info["receipt"]}) request: Response = self.session.post( self._document_root + f"/ws/{zone}/update/documents", params=self.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps(data), ) self._raise_if_error(request) return request.json() def send_file( self, folder_id: str, file_object: IO, zone: str = CLOUD_DOCS_ZONE, **kwargs, ) -> None: """Send new file to iCloud Drive.""" document_id, content_url = self._get_upload_contentws_url( file_object=file_object, zone=zone ) request: Response = self.session.post( content_url, files={file_object.name: file_object} ) self._raise_if_error(request) content_response = request.json()["singleFile"] self._update_contentws( folder_id, content_response, document_id, file_object, zone, **kwargs, ) def create_folders(self, parent: str, name: str): """Creates a new iCloud Drive folder""" # when creating a folder on icloud.com, the clientID is set to the following: temp_client_id: str = f"FOLDER::UNKNOWN_ZONE::TempId-{uuid.uuid4()}" request: Response = self.session.post( self.service_root + "/createFolders", params=self.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "destinationDrivewsId": parent, "folders": [ { "clientId": temp_client_id, "name": name, } ], } ), ) self._raise_if_error(request) return request.json() def delete_items(self, node_id: str, etag: str): """Deletes an iCloud Drive node""" request: Response = self.session.post( self.service_root + "/deleteItems", params=self.params, data=json.dumps( { "items": [ { "drivewsid": node_id, "etag": etag, "clientId": self.params["clientId"], } ], } ), ) self._raise_if_error(request) return request.json() def rename_items(self, node_id: str, etag: str, name: str): """Renames an iCloud Drive node""" request: Response = self.session.post( self.service_root + "/renameItems", params=self.params, data=json.dumps( { "items": [ { "drivewsid": node_id, "etag": etag, "name": name, } ], } ), ) self._raise_if_error(request) return request.json() def move_items_to_trash(self, node_id: str, etag: str): """Moves an iCloud Drive node to the trash bin""" # when moving a node to the trash on icloud.com, the clientID is set to the node_id: temp_client_id: str = node_id request: Response = self.session.post( self.service_root + "/moveItemsToTrash", params=self.params, data=json.dumps( { "items": [ { "drivewsid": node_id, "etag": etag, "clientId": temp_client_id, } ], } ), ) self._raise_if_error(request) return request.json() def recover_items_from_trash(self, node_id: str, etag: str): """Restores an iCloud Drive node from the trash bin""" request: Response = self.session.post( self.service_root + "/putBackItemsFromTrash", params=self.params, data=json.dumps( { "items": [ { "drivewsid": node_id, "etag": etag, } ], } ), ) self._raise_if_error(request) return request.json() def delete_forever_from_trash(self, node_id: str, etag: str): """Permanently deletes an iCloud Drive node from the trash bin""" request: Response = self.session.post( self.service_root + "/deleteItems", params=self.params, data=json.dumps( { "items": [ { "drivewsid": node_id, "etag": etag, } ], } ), ) self._raise_if_error(request) return request.json() @property def root(self) -> "DriveNode": """Returns the root node.""" if not self._root: self.refresh_root() if self._root: return self._root raise ValueError("Root not found") @property def trash(self) -> "DriveNode": """Returns the trash node.""" if not self._trash: self.refresh_trash() if self._trash: return self._trash raise ValueError("Trash not found") def refresh_root(self) -> None: """Refreshes and returns a fresh root node.""" self._root = DriveNode(self, self.get_node_data(CLOUD_DOCS_ZONE_ID_ROOT)) def refresh_trash(self) -> None: """Refreshes and returns a fresh trash node.""" self._trash = DriveNode(self, self.get_node_data(CLOUD_DOCS_ZONE_ID_TRASH)) def __getattr__(self, attr): return getattr(self.root, attr) def __getitem__(self, key: str) -> "DriveNode": return self.root[key] @staticmethod def _raise_if_error(response: Response) -> None: if not response.ok: api_error = PyiCloudAPIResponseException( response.reason, response.status_code ) LOGGER.error(api_error) raise api_error class DriveNode: """Drive node.""" TYPE_UNKNOWN = "unknown" TYPE_TRASH = "trash" NAME_ROOT = "root" NAME_UNKNOWN = "" def __init__(self, conn, data) -> None: self.data = data self.connection = conn self._children: Optional[list[DriveNode]] = None @property def name(self) -> str: """Gets the node name.""" # check if name is undefined, return drivewsid instead if so. node_name: Optional[str] = self.data.get("name") if not node_name: # use drivewsid as name if no name present. node_name = self.data.get("drivewsid") # Clean up well-known drivewsid names if node_name == CLOUD_DOCS_ZONE_ID_ROOT: node_name = self.NAME_ROOT # if no name still, return unknown string. if not node_name: node_name = self.NAME_UNKNOWN if "extension" in self.data: return f"{node_name}.{self.data['extension']}" return node_name @property def type(self) -> str: """Gets the node type.""" node_type: Optional[str] = self.data.get("type") # handle trash which has no node type if not node_type and self.data.get("drivewsid") == NODE_TRASH: node_type = self.TYPE_TRASH if not node_type: node_type = self.TYPE_UNKNOWN return node_type.lower() def get_children(self, force: bool = False) -> list["DriveNode"]: """Gets the node children.""" if not self._children or force: if "items" not in self.data or force: self.data.update(self.connection.get_node_data(self.data["drivewsid"])) if "items" not in self.data: raise KeyError("No items in folder, status: %s" % self.data["status"]) self._children = [ DriveNode(self.connection, item_data) for item_data in self.data["items"] ] return self._children def remove(self, child: "DriveNode") -> None: """Removes a child from the node.""" if self._children: for item_data in self.data["items"]: if item_data["docwsid"] == child.data["docwsid"]: self.data["items"].remove(item_data) break self._children.remove(child) else: raise ValueError("No children to remove") @property def size(self) -> Optional[int]: """Gets the node size.""" size: Optional[str] = self.data.get("size") # Folder does not have size if not size: return None return int(size) @property def date_changed(self) -> Optional[datetime]: """Gets the node changed date (in UTC).""" return _date_to_utc(self.data.get("dateChanged")) # Folder does not have date @property def date_modified(self) -> Optional[datetime]: """Gets the node modified date (in UTC).""" return _date_to_utc(self.data.get("dateModified")) # Folder does not have date @property def date_last_open(self) -> Optional[datetime]: """Gets the node last open date (in UTC).""" return _date_to_utc(self.data.get("lastOpenTime")) # Folder does not have date def open(self, **kwargs): """Gets the node file.""" # iCloud returns 400 Bad Request for 0-byte files if self.data["size"] == 0: response = Response() response.raw = io.BytesIO() return response return self.connection.get_file( self.data["docwsid"], zone=self.data["zone"], **kwargs ) def upload(self, file_object, **kwargs): """Upload a new file.""" return self.connection.send_file( self.data["docwsid"], file_object, zone=self.data["zone"], **kwargs ) def dir(self) -> list[str]: """Gets the node list of directories.""" if self.type == "file": raise NotADirectoryError(self.name) return [child.name for child in self.get_children()] def mkdir(self, folder: str): """Create a new directory directory.""" return self.connection.create_folders(self.data["drivewsid"], folder) def rename(self, name: str): """Rename an iCloud Drive item.""" return self.connection.rename_items( self.data["drivewsid"], self.data["etag"], name ) def move_to_trash(self): """Move an iCloud Drive item to the trash bin (Recently Deleted).""" return self.connection.move_items_to_trash( self.data["drivewsid"], self.data["etag"] ) def delete(self): """Delete an iCloud Drive item.""" return self.connection.delete_items(self.data["drivewsid"], self.data["etag"]) def recover(self): """Recovers an iCloud Drive item from trash.""" # check to ensure item is in the trash - it should have a "restorePath" property if self.data.get("restorePath"): return self.connection.recover_items_from_trash( self.data["drivewsid"], self.data["etag"] ) else: raise ValueError(f"'{self.name}' does not appear to be in the Trash.") def delete_forever(self): """Permanently deletes an iCloud Drive item from trash.""" # check to ensure item is in the trash - it should have a "restorePath" property if self.data.get("restorePath"): return self.connection.delete_forever_from_trash( self.data["drivewsid"], self.data["etag"] ) else: raise ValueError( f"'{self.name}' does not appear to be in the Trash. Please 'delete()' it first before " f"trying to 'delete_forever()'." ) def get(self, name: str) -> "DriveNode": """Gets the node child.""" if self.type == "file": raise NotADirectoryError(name) return [child for child in self.get_children() if child.name == name][0] def __getitem__(self, key: str) -> "DriveNode": try: return self.get(key) except IndexError as i: raise KeyError(f"No child named '{key}' exists") from i def __str__(self) -> str: return "{" + f"type: {self.type}, name: {self.name}" + "}" def __repr__(self) -> str: return f"<{type(self).__name__}: {str(self)}>" def _date_to_utc(date) -> Optional[datetime]: if not date: return None # jump through hoops to return time in UTC rather than California time match: Optional[Match[str]] = search(r"^(.+?)([\+\-]\d+):(\d\d)$", date) if not match: # Already in UTC return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") base: datetime = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S") diff = timedelta(hours=int(match.group(2)), minutes=int(match.group(3))) return base - diff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/findmyiphone.py0000644000175100001660000001563615023360704021476 0ustar00runnerdocker"""Find my iPhone service.""" import json from typing import Any, Iterator, Optional from requests import Response from pyicloud.exceptions import PyiCloudNoDevicesException from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession class FindMyiPhoneServiceManager(BaseService): """The 'Find my iPhone' iCloud service This connects to iCloud and return phone data including the near-realtime latitude and longitude. """ def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any], with_family=False, ) -> None: super().__init__(service_root, session, params) self.with_family: bool = with_family fmip_endpoint: str = f"{service_root}/fmipservice/client/web" self._fmip_refresh_url: str = f"{fmip_endpoint}/refreshClient" self._fmip_sound_url: str = f"{fmip_endpoint}/playSound" self._fmip_message_url: str = f"{fmip_endpoint}/sendMessage" self._fmip_lost_url: str = f"{fmip_endpoint}/lostDevice" self._devices: dict[str, AppleDevice] = {} self.refresh_client() def refresh_client(self) -> None: """Refreshes the FindMyiPhoneService endpoint, This ensures that the location data is up-to-date. """ req: Response = self.session.post( self._fmip_refresh_url, params=self.params, data=json.dumps( { "clientContext": { "appName": "iCloud Find (Web)", "appVersion": "2.0", "apiVersion": "3.0", "deviceListVersion": 1, "fmly": self.with_family, } } ), ) self.response: dict[str, Any] = req.json() for device_info in self.response["content"]: device_id: str = device_info["id"] if device_id not in self._devices: self._devices[device_id] = AppleDevice( device_info, self.params, manager=self, sound_url=self._fmip_sound_url, lost_url=self._fmip_lost_url, message_url=self._fmip_message_url, ) else: self._devices[device_id].update(device_info) if not self._devices: raise PyiCloudNoDevicesException() def __getitem__(self, key) -> "AppleDevice": if isinstance(key, int): key = list(self.keys())[key] return self._devices[key] def __getattr__(self, attr: str) -> Any: return getattr(self._devices, attr) def __str__(self) -> str: return f"{self._devices}" def __repr__(self) -> str: return f"{self}" def __iter__(self) -> Iterator["AppleDevice"]: return iter(self._devices.values()) def __len__(self) -> int: return len(self._devices) class AppleDevice: """Apple device.""" def __init__( self, content: dict[str, Any], params: dict[str, Any], manager: FindMyiPhoneServiceManager, sound_url: str, lost_url: str, message_url: str, ) -> None: self.content: dict[str, Any] = content self.manager: FindMyiPhoneServiceManager = manager self.params: dict[str, Any] = params self.sound_url: str = sound_url self.lost_url: str = lost_url self.message_url: str = message_url @property def session(self) -> PyiCloudSession: """Gets the session.""" return self.manager.session def update(self, data) -> None: """Updates the device data.""" self.content = data @property def location(self): """Updates the device location.""" self.manager.refresh_client() return self.content["location"] def status(self, additional: Optional[list[str]] = None) -> dict[str, Any]: """Returns status information for device. This returns only a subset of possible properties. """ self.manager.refresh_client() fields: list[str] = [ "batteryLevel", "deviceDisplayName", "deviceStatus", "name", ] if additional is not None: fields += additional properties: dict[str, Any] = {} for field in fields: properties[field] = self.content.get(field) return properties def play_sound(self, subject="Find My iPhone Alert") -> None: """Send a request to the device to play a sound. It's possible to pass a custom message by changing the `subject`. """ data: str = json.dumps( { "device": self.content["id"], "subject": subject, "clientContext": {"fmly": True}, } ) self.session.post(self.sound_url, params=self.params, data=data) def display_message( self, subject="Find My iPhone Alert", message="This is a note", sounds=False ) -> None: """Send a request to the device to play a sound. It's possible to pass a custom message by changing the `subject`. """ data: str = json.dumps( { "device": self.content["id"], "subject": subject, "sound": sounds, "userText": True, "text": message, } ) self.session.post(self.message_url, params=self.params, data=data) def lost_device( self, number, text="This iPhone has been lost. Please call me.", newpasscode="" ) -> None: """Send a request to the device to trigger 'lost mode'. The device will show the message in `text`, and if a number has been passed, then the person holding the device can call the number without entering the passcode. """ data: str = json.dumps( { "text": text, "userText": True, "ownerNbr": number, "lostModeEnabled": True, "trackingEnabled": True, "device": self.content["id"], "passcode": newpasscode, } ) self.session.post(self.lost_url, params=self.params, data=data) @property def data(self) -> dict[str, Any]: """Gets the device data.""" return self.content def __getitem__(self, key) -> Any: return self.content[key] def __getattr__(self, attr) -> Any: if attr in self.content: return self.content[attr] raise AttributeError( f"'{type(self).__name__}' object has no attribute '{attr}'" ) def __str__(self) -> str: return f"{self['deviceDisplayName']}: {self['name']}" def __repr__(self) -> str: return f"" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/hidemyemail.py0000644000175100001660000001535115023360704021266 0ustar00runnerdocker"""Hide my email service.""" import json from typing import Any, Generator, Optional from requests import Response from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession class HideMyEmailService(BaseService): """ The 'Hide My Email' iCloud service connects to iCloud and manages email aliases. This service allows users to: - Generate new email aliases - Reserve specific aliases - List all existing aliases - Get alias details by ID - Update alias metadata (label, note) - Delete aliases - Deactivate aliases - Reactivate aliases """ def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: super().__init__(service_root, session, params) # Define v1 endpoints self._v1_endpoint: str = f"{service_root}/v1/hme" self._generate_endpoint: str = f"{self._v1_endpoint}/generate" self._reserve_endpoint: str = f"{self._v1_endpoint}/reserve" self._update_metadata_endpoint: str = f"{self._v1_endpoint}/updateMetaData" self._delete_endpoint: str = f"{self._v1_endpoint}/delete" self._deactivate_endpoint: str = f"{self._v1_endpoint}/deactivate" self._reactivate_endpoint: str = f"{self._v1_endpoint}/reactivate" # Define v2 endpoints self._v2_endpoint: str = f"{service_root}/v2/hme" self._list_endpoint: str = f"{self._v2_endpoint}/list" self._get_endpoint: str = f"{self._v2_endpoint}/get" def generate(self) -> Optional[str]: """ Generate a new email alias. Returns: The generated email address string or None if generation failed. """ req: Response = self.session.post(self._generate_endpoint, params=self.params) response: dict[str, dict[str, str]] = req.json() result: Optional[dict[str, str]] = response.get("result") if result: return result.get("hme") return None def reserve(self, email: str, label: str, note="Generated") -> dict[str, Any]: """ Reserve an alias for emails. Args: email: The email alias to reserve. label: A label for the email alias. note: An optional note for the email alias. Returns: The API's result containing details about the reserved alias. """ req: Response = self.session.post( self._reserve_endpoint, params=self.params, data=json.dumps( { "hme": email, "label": label, "note": note, } ), ) response = req.json() return response.get("result", {}) def __len__(self) -> int: """ Get the number of emails """ req: Response = self.session.get(self._list_endpoint, params=self.params) response: dict[str, dict[str, str]] = req.json() result: Optional[dict[str, str]] = response.get("result") if result: return len(result.get("hmeEmails", [])) return 0 def __iter__(self) -> Generator[Any, Any, None]: """ Iterate over the list of emails """ req: Response = self.session.get(self._list_endpoint, params=self.params) response: dict[str, dict[str, str]] = req.json() result: Optional[dict[str, str]] = response.get("result") if result: yield from result.get("hmeEmails", []) def __getitem__(self, anonymous_id: str) -> dict[str, Any]: """ Get alias email details by anonymous_id. Args: anonymous_id: The unique identifier for the alias. Returns: A dictionary containing details about the alias. """ req: Response = self.session.post( self._get_endpoint, params=self.params, data=json.dumps({"anonymousId": anonymous_id}), ) response = req.json() return response.get("result", {}) def update_metadata( self, anonymous_id: str, label: str, note: Optional[str] = None ) -> dict[str, Any]: """ Update metadata for an alias email. Args: anonymous_id: The unique identifier for the alias. label: The new label for the alias. note: The new note for the alias (optional). Returns: A dictionary containing the API response. """ payload: dict[str, str] = { "anonymousId": anonymous_id, "label": label, } if note is not None: payload["note"] = note req: Response = self.session.post( self._update_metadata_endpoint, params=self.params, data=json.dumps(payload), ) response = req.json() return response.get("result", {}) def delete(self, anonymous_id: str) -> dict[str, Any]: """ Delete an alias email. Args: anonymous_id: The unique identifier for the alias to delete. Returns: A dictionary containing the API response. """ req: Response = self.session.post( self._delete_endpoint, params=self.params, data=json.dumps({"anonymousId": anonymous_id}), ) response = req.json() return response.get("result", {}) def deactivate(self, anonymous_id: str) -> dict[str, Any]: """ Deactivate an alias email. Deactivating an alias means emails sent to it will no longer be forwarded, but the alias remains in your list and can be reactivated later. Args: anonymous_id: The unique identifier for the alias to deactivate. Returns: A dictionary containing the API response. """ req: Response = self.session.post( self._deactivate_endpoint, params=self.params, data=json.dumps({"anonymousId": anonymous_id}), ) response = req.json() return response.get("result", {}) def reactivate(self, anonymous_id: str) -> dict[str, Any]: """ Reactivate a previously deactivated alias email. Reactivating an alias means emails sent to it will be forwarded again to your primary inbox. Args: anonymous_id: The unique identifier for the alias to reactivate. Returns: A dictionary containing the API response. """ req: Response = self.session.post( self._reactivate_endpoint, params=self.params, data=json.dumps({"anonymousId": anonymous_id}), ) response = req.json() return response.get("result", {}) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/photos.py0000644000175100001660000011532615023360704020316 0ustar00runnerdocker"""Photo service.""" import base64 import json import logging import os from abc import abstractmethod from datetime import datetime, timezone from enum import IntEnum, unique from typing import Any, Generator, Iterable, Iterator, Optional, cast from urllib.parse import urlencode from requests import Response from pyicloud.const import CONTENT_TYPE, CONTENT_TYPE_TEXT from pyicloud.exceptions import ( PyiCloudAPIResponseException, PyiCloudServiceNotActivatedException, ) from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession _LOGGER: logging.Logger = logging.getLogger(__name__) @unique class AlbumTypeEnum(IntEnum): """Album types""" ALBUM = 0 FOLDER = 3 SMART_ALBUM = 6 class SmartAlbumEnum: """Smart albums names.""" ALL_PHOTOS = "Library" BURSTS = "Bursts" FAVORITES = "Favorites" HIDDEN = "Hidden" LIVE = "Live" PANORAMAS = "Panoramas" RECENTLY_DELETED = "Recently Deleted" SCREENSHOTS = "Screenshots" SLO_MO = "Slo-mo" TIME_LAPSE = "Time-lapse" VIDEOS = "Videos" class DirectionEnum: """Direction names.""" ASCENDING = "ASCENDING" DESCENDING = "DESCENDING" class ListTypeEnum: """List type names.""" DEFAULT: str = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted" DELETED: str = "CPLAssetAndMasterDeletedByExpungedDate" HIDDEN: str = "CPLAssetAndMasterHiddenByAssetDate" SMART_ALBUM: str = "CPLAssetAndMasterInSmartAlbumByAssetDate" STACK: str = "CPLBurstStackAssetAndMasterByAssetDate" class ObjectTypeEnum: """Object type names.""" ALL: str = "CPLAssetByAssetDateWithoutHiddenOrDeleted" BURST: str = "CPLAssetBurstStackAssetByAssetDate" DELETED: str = "CPLAssetDeletedByExpungedDate" FAVORITE: str = "CPLAssetInSmartAlbumByAssetDate:Favorite" HIDDEN: str = "CPLAssetHiddenByAssetDate" LIVE: str = "CPLAssetInSmartAlbumByAssetDate:Live" PANORAMA: str = "CPLAssetInSmartAlbumByAssetDate:Panorama" SCREENSHOT: str = "CPLAssetInSmartAlbumByAssetDate:Screenshot" SLOMO: str = "CPLAssetInSmartAlbumByAssetDate:Slomo" TIMELASPE: str = "CPLAssetInSmartAlbumByAssetDate:Timelapse" VIDEO: str = "CPLAssetInSmartAlbumByAssetDate:Video" # The primary zone for the user's photo library PRIMARY_ZONE: dict[str, str] = { "zoneName": "PrimarySync", "zoneType": "REGULAR_CUSTOM_ZONE", } class AlbumContainer(Iterable): """Container for photo albums. This provides a way to access all the albums in the library. """ def __init__(self, albums: dict[str, "BasePhotoAlbum"]) -> None: self._albums: dict[str, "BasePhotoAlbum"] = albums def __len__(self) -> int: return len(self._albums) def __getitem__(self, name) -> "BasePhotoAlbum": if name in self._albums: return self._albums[name] for album in self._albums.values(): if name == album.fullname: return album raise KeyError(f"Photo album does not exist: {name}") def __iter__(self) -> Iterator[str]: return self._albums.__iter__() def values(self): """Returns the values of the albums.""" return self._albums.values() class BasePhotoLibrary: """Represents a library in the user's photos. This provides access to all the albums as well as the photos. """ def __init__( self, service: "PhotosService", upload_url: Optional[str] = None, ) -> None: self.service: PhotosService = service self._albums: Optional[AlbumContainer] = None self._upload_url: Optional[str] = upload_url @abstractmethod def _get_albums(self) -> dict[str, "BasePhotoAlbum"]: """Returns the photo albums.""" raise NotImplementedError @property def albums(self) -> AlbumContainer: """Returns the photo albums.""" if self._albums is None: self._albums = AlbumContainer(self._get_albums()) return self._albums class PhotoLibrary(BasePhotoLibrary): """Represents the user's primary photo libraries.""" SMART_ALBUMS: dict[str, dict[str, Any]] = { SmartAlbumEnum.ALL_PHOTOS: { "obj_type": ObjectTypeEnum.ALL, "list_type": ListTypeEnum.DEFAULT, "direction": DirectionEnum.DESCENDING, "query_filter": None, }, SmartAlbumEnum.TIME_LAPSE: { "obj_type": ObjectTypeEnum.TIMELASPE, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "TIMELAPSE"}, } ], }, SmartAlbumEnum.VIDEOS: { "obj_type": ObjectTypeEnum.VIDEO, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "VIDEO"}, } ], }, SmartAlbumEnum.SLO_MO: { "obj_type": ObjectTypeEnum.SLOMO, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "SLOMO"}, } ], }, SmartAlbumEnum.BURSTS: { "obj_type": ObjectTypeEnum.BURST, "list_type": ListTypeEnum.STACK, "direction": DirectionEnum.ASCENDING, "query_filter": None, }, SmartAlbumEnum.FAVORITES: { "obj_type": ObjectTypeEnum.FAVORITE, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "FAVORITE"}, } ], }, SmartAlbumEnum.PANORAMAS: { "obj_type": ObjectTypeEnum.PANORAMA, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "PANORAMA"}, } ], }, SmartAlbumEnum.SCREENSHOTS: { "obj_type": ObjectTypeEnum.SCREENSHOT, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "SCREENSHOT"}, } ], }, SmartAlbumEnum.LIVE: { "obj_type": ObjectTypeEnum.LIVE, "list_type": ListTypeEnum.SMART_ALBUM, "direction": DirectionEnum.ASCENDING, "query_filter": [ { "fieldName": "smartAlbum", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "LIVE"}, } ], }, SmartAlbumEnum.RECENTLY_DELETED: { "obj_type": ObjectTypeEnum.DELETED, "list_type": ListTypeEnum.DELETED, "direction": DirectionEnum.ASCENDING, "query_filter": None, }, SmartAlbumEnum.HIDDEN: { "obj_type": ObjectTypeEnum.HIDDEN, "list_type": ListTypeEnum.HIDDEN, "direction": DirectionEnum.ASCENDING, "query_filter": None, }, } def __init__( self, service: "PhotosService", zone_id: dict[str, str], upload_url: Optional[str] = None, ) -> None: super().__init__(service, upload_url) self.zone_id: dict[str, str] = zone_id self.url: str = f"{self.service.service_endpoint}/records/query?{urlencode(self.service.params)}" json_data: str = json.dumps( { "query": { "recordType": "CheckIndexingState", }, "zoneID": self.zone_id, } ) request: Response = self.service.session.post( url=self.url, data=json_data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response: dict[str, Any] = request.json() indexing_state: str = response["records"][0]["fields"]["state"]["value"] if indexing_state != "FINISHED": _LOGGER.debug("iCloud Photo Library not finished indexing") raise PyiCloudServiceNotActivatedException( "iCloud Photo Library not finished indexing. " "Please try again in a few minutes." ) def _fetch_records(self, parent_id: Optional[str] = None) -> list[dict[str, Any]]: """Fetches records.""" query: dict[str, Any] = { "query": { "recordType": "CPLAlbumByPositionLive", }, "zoneID": self.zone_id, } if parent_id: query["query"]["filterBy"] = [ { "fieldName": "parentId", "comparator": "EQUALS", "fieldValue": { "value": parent_id, "type": "STRING", }, } ] request: Response = self.service.session.post( url=self.url, data=json.dumps(query), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response: dict[str, list[dict[str, Any]]] = request.json() records: list[dict[str, Any]] = response["records"] while "continuationMarker" in response: query["continuationMarker"] = response["continuationMarker"] request: Response = self.service.session.post( url=self.url, data=json.dumps(query), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response = request.json() records.extend(response["records"]) for record in records.copy(): if ( record["fields"].get("albumType") and record["fields"]["albumType"]["value"] == AlbumTypeEnum.FOLDER.value ): records.extend(self._fetch_records(parent_id=record["recordName"])) return records def _get_albums(self) -> dict[str, "BasePhotoAlbum"]: """Returns photo albums.""" albums: dict[str, "BasePhotoAlbum"] = { name: PhotoAlbum( library=self, name=name, zone_id=self.zone_id, url=self.url, **props, ) for (name, props) in self.SMART_ALBUMS.items() } for record in self._fetch_records(): if ( # Skipping albums having null name, that can happen sometime "albumNameEnc" not in record["fields"] or ( record["fields"].get("albumType") and record["fields"]["albumType"]["value"] == AlbumTypeEnum.FOLDER.value ) or ( record["fields"].get("isDeleted") and record["fields"]["isDeleted"]["value"] ) ): continue record_id: str = record["recordName"] obj_type: str = f"CPLContainerRelationNotDeletedByAssetDate:{record_id}" album_name: str = base64.b64decode( record["fields"]["albumNameEnc"]["value"] ).decode("utf-8") query_filter: list[dict[str, Any]] = [ { "fieldName": "parentId", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": record_id}, } ] parent_id: Optional[str] = record["fields"].get("parentId", {}).get("value") photo_album = PhotoAlbum( library=self, name=album_name, list_type="CPLContainerRelationLiveByAssetDate", obj_type=obj_type, direction=DirectionEnum.ASCENDING, url=self.url, query_filter=query_filter, zone_id=self.zone_id, parent_id=parent_id, ) albums[record_id] = photo_album return albums def upload_file(self, path: str) -> dict[str, Any]: """Upload a photo from path, returns a recordName""" filename: str = os.path.basename(path) url: str = f"{self._upload_url}/upload" with open(path, "rb") as file_obj: request: Response = self.service.session.post( url=url, data=file_obj.read(), params={ "filename": filename, "dsid": self.service.params["dsid"], }, ) if "errors" in request.json(): raise PyiCloudAPIResponseException("", request.json()["errors"]) return [ x["recordName"] for x in request.json()["records"] if x["recordType"] == "CPLAsset" ][0] @property def all(self) -> "PhotoAlbum": """Returns the All Photos album.""" return cast(PhotoAlbum, self.albums[SmartAlbumEnum.ALL_PHOTOS]) class PhotoStreamLibrary(BasePhotoLibrary): """Represents a shared photo library.""" def __init__( self, service: "PhotosService", shared_streams_url: str, ) -> None: super().__init__(service) self.shared_streams_url: str = shared_streams_url def _get_albums(self) -> dict[str, "BasePhotoAlbum"]: """Returns albums.""" albums: dict[str, BasePhotoAlbum] = {} url: str = f"{self.shared_streams_url}?{urlencode(self.service.params)}" json_data: str = json.dumps({}) request: Response = self.service.session.post( url, data=json_data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT} ) response: dict[str, list] = request.json() for album in response["albums"]: shared_stream = SharedPhotoStreamAlbum( library=self, name=album["attributes"]["name"], album_location=album["albumlocation"], album_ctag=album["albumctag"], album_guid=album["albumguid"], owner_dsid=album["ownerdsid"], creation_date=album["attributes"]["creationDate"], sharing_type=album["sharingtype"], allow_contributions=album["attributes"]["allowcontributions"], is_public=album["attributes"]["ispublic"], is_web_upload_supported=album["iswebuploadsupported"], public_url=album.get("publicurl", None), ) albums[album["attributes"]["name"]] = shared_stream return albums class PhotosService(BaseService): """The 'Photos' iCloud service. This also acts as a way to access the user's primary library.""" def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any], upload_url: str, shared_streams_url: str, ) -> None: BaseService.__init__( self, service_root=service_root, session=session, params=params, ) self.service_endpoint: str = ( f"{self.service_root}/database/1/com.apple.photos.cloud/production/private" ) self._libraries: Optional[dict[str, BasePhotoLibrary]] = None self.params.update({"remapEnums": True, "getCurrentSyncToken": True}) self._photo_assets: dict = {} self._root_library: PhotoLibrary = PhotoLibrary( self, PRIMARY_ZONE, upload_url=upload_url, ) self._shared_library: PhotoStreamLibrary = PhotoStreamLibrary( self, shared_streams_url=f"{shared_streams_url}/{self.params['dsid']}/sharedstreams/webgetalbumslist", ) @property def libraries(self) -> dict[str, BasePhotoLibrary]: """Returns photo libraries.""" if not self._libraries: url: str = f"{self.service_endpoint}/changes/database" request: Response = self.session.post( url, data="{}", headers={CONTENT_TYPE: CONTENT_TYPE_TEXT} ) response: dict[str, Any] = request.json() zones: list[dict[str, Any]] = response["zones"] libraries: dict[str, BasePhotoLibrary] = { "root": self._root_library, "shared": self._shared_library, } for zone in zones: if not zone.get("deleted"): zone_name: str = zone["zoneID"]["zoneName"] libraries[zone_name] = PhotoLibrary(self, zone["zoneID"]) self._libraries = libraries return self._libraries @property def all(self) -> "PhotoAlbum": """Returns the primary photo library.""" return self._root_library.all @property def albums(self) -> AlbumContainer: """Returns the standard photo albums.""" return self._root_library.albums @property def shared_streams(self) -> AlbumContainer: """Returns the shared photo albums.""" return self._shared_library.albums class BasePhotoAlbum: """An abstract photo album.""" def __init__( self, library: BasePhotoLibrary, name: str, list_type: str, asset_type: type["PhotoAsset"], page_size: int = 100, direction: str = DirectionEnum.ASCENDING, ) -> None: self.name: str = name self._library: BasePhotoLibrary = library self.page_size: int = page_size self.direction: str = direction self.list_type: str = list_type self.asset_type: type[PhotoAsset] = asset_type self._len: Optional[int] = None @property @abstractmethod def fullname(self) -> str: """Gets the full name of the album including path""" raise NotImplementedError @property def service(self) -> PhotosService: """Get the Photo service""" return self._library.service def _parse_response( self, response: dict[str, list[dict[str, Any]]] ) -> tuple[dict[str, Any], list[dict[str, Any]]]: asset_records: dict[str, dict[str, Any]] = {} master_records: list[dict[str, Any]] = [] for rec in response["records"]: if rec["recordType"] == "CPLAsset": master_id: str = rec["fields"]["masterRef"]["value"]["recordName"] asset_records[master_id] = rec elif rec["recordType"] == "CPLMaster": master_records.append(rec) return asset_records, master_records def _get_photos_at( self, index: int, direction: str, page_size=100 ) -> Generator["PhotoAsset", None, None]: offset: int = ( len(self) - index - 1 if direction == DirectionEnum.DESCENDING else index ) response: Response = self.service.session.post( url=self._get_url(), data=json.dumps( self._get_payload( offset=offset, page_size=page_size, direction=direction, ) ), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) json_response: dict[str, list[dict[str, Any]]] = response.json() asset_records, master_records = self._parse_response(json_response) for master_record in master_records: record_name: str = master_record["recordName"] yield self.asset_type( self.service, master_record, asset_records[record_name] ) def photo(self, index) -> Generator["PhotoAsset", None, None]: """Returns a photo at the given index.""" return self._get_photos_at(index, self.direction, 2) @property def title(self) -> str: """Gets the album name.""" return self.name @property def photos(self) -> Generator["PhotoAsset", None, None]: """Returns the album photos.""" self._len = None if self.direction == DirectionEnum.DESCENDING: offset: int = len(self) - 1 else: offset = 0 while True: num_results = 0 for photo in self._get_photos_at( offset, self.direction, self.page_size * 2 ): num_results += 1 yield photo if num_results == 0: break if self.direction == DirectionEnum.DESCENDING: offset = offset - num_results else: offset = offset + num_results @abstractmethod def _get_payload( self, offset: int, page_size: int, direction: str ) -> dict[str, str]: """Returns the payload for the photo list request.""" raise NotImplementedError @abstractmethod def _get_url(self) -> str: """Returns the URL for the photo list request.""" raise NotImplementedError @abstractmethod def _get_len(self) -> int: """Returns the number of photos in the album.""" raise NotImplementedError def __iter__(self) -> Generator["PhotoAsset", None, None]: return self.photos def __len__(self) -> int: if self._len is None: self._len = self._get_len() return self._len def __str__(self) -> str: return self.title def __repr__(self) -> str: return f"<{type(self).__name__}: '{self}'>" class PhotoAlbum(BasePhotoAlbum): """A photo album.""" def __init__( self, library: BasePhotoLibrary, name: str, list_type: str, obj_type: str, direction: str, url: str, query_filter: Optional[list[dict[str, Any]]] = None, zone_id: Optional[dict[str, str]] = None, page_size: int = 100, parent_id: Optional[str] = None, ) -> None: super().__init__( library=library, name=name, list_type=list_type, page_size=page_size, direction=direction, asset_type=PhotoAsset, ) self.obj_type: str = obj_type self.query_filter: Optional[list[dict[str, Any]]] = query_filter self.url: str = url self._parent_id: Optional[str] = parent_id if zone_id: self.zone_id: dict[str, str] = zone_id else: self.zone_id = PRIMARY_ZONE @property def fullname(self) -> str: if self._parent_id is not None: return f"{self._library.albums[self._parent_id].fullname}/{self.name}" return self.name def _get_len(self) -> int: url: str = f"{self.service.service_endpoint}/internal/records/query/batch?{urlencode(self.service.params)}" request: Response = self.service.session.post( url, data=json.dumps( { "batch": [ { "resultsLimit": 1, "query": { "recordType": "HyperionIndexCountLookup", "filterBy": { "fieldName": "indexCountID", "comparator": "IN", "fieldValue": { "type": "STRING_LIST", "value": [self.obj_type], }, }, }, "zoneWide": True, "zoneID": self.zone_id, } ] } ), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response: dict[str, Any] = request.json() return response["batch"][0]["records"][0]["fields"]["itemCount"]["value"] def _get_payload( self, offset: int, page_size: int, direction: str ) -> dict[str, str]: return self._list_query_gen( offset, self.list_type, direction, page_size, self.query_filter, ) def _get_url(self) -> str: return self.url def _list_query_gen( self, offset: int, list_type: str, direction: str, num_results: int, query_filter=None, ) -> dict[str, Any]: query: dict[str, Any] = { "query": { "recordType": list_type, "filterBy": [ { "fieldName": "direction", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": direction}, }, { "fieldName": "startRank", "comparator": "EQUALS", "fieldValue": {"type": "INT64", "value": offset}, }, ], }, "resultsLimit": num_results, "desiredKeys": [ "resJPEGFullWidth", "resJPEGFullHeight", "resJPEGFullFileType", "resJPEGFullFingerprint", "resJPEGFullRes", "resJPEGLargeWidth", "resJPEGLargeHeight", "resJPEGLargeFileType", "resJPEGLargeFingerprint", "resJPEGLargeRes", "resJPEGMedWidth", "resJPEGMedHeight", "resJPEGMedFileType", "resJPEGMedFingerprint", "resJPEGMedRes", "resJPEGThumbWidth", "resJPEGThumbHeight", "resJPEGThumbFileType", "resJPEGThumbFingerprint", "resJPEGThumbRes", "resVidFullWidth", "resVidFullHeight", "resVidFullFileType", "resVidFullFingerprint", "resVidFullRes", "resVidMedWidth", "resVidMedHeight", "resVidMedFileType", "resVidMedFingerprint", "resVidMedRes", "resVidSmallWidth", "resVidSmallHeight", "resVidSmallFileType", "resVidSmallFingerprint", "resVidSmallRes", "resSidecarWidth", "resSidecarHeight", "resSidecarFileType", "resSidecarFingerprint", "resSidecarRes", "itemType", "dataClassType", "filenameEnc", "originalOrientation", "resOriginalWidth", "resOriginalHeight", "resOriginalFileType", "resOriginalFingerprint", "resOriginalRes", "resOriginalAltWidth", "resOriginalAltHeight", "resOriginalAltFileType", "resOriginalAltFingerprint", "resOriginalAltRes", "resOriginalVidComplWidth", "resOriginalVidComplHeight", "resOriginalVidComplFileType", "resOriginalVidComplFingerprint", "resOriginalVidComplRes", "isDeleted", "isExpunged", "dateExpunged", "remappedRef", "recordName", "recordType", "recordChangeTag", "masterRef", "adjustmentRenderType", "assetDate", "addedDate", "isFavorite", "isHidden", "orientation", "duration", "assetSubtype", "assetSubtypeV2", "assetHDRType", "burstFlags", "burstFlagsExt", "burstId", "captionEnc", "locationEnc", "locationV2Enc", "locationLatitude", "locationLongitude", "adjustmentType", "timeZoneOffset", "vidComplDurValue", "vidComplDurScale", "vidComplDispValue", "vidComplDispScale", "vidComplVisibilityState", "customRenderedValue", "containerId", "itemId", "position", "isKeyAsset", ], "zoneID": self.zone_id, } if query_filter: query["query"]["filterBy"].extend(query_filter) return query class SharedPhotoStreamAlbum(BasePhotoAlbum): """A Shared Stream Photo Album.""" def __init__( self, library: BasePhotoLibrary, name: str, album_location: str, album_ctag: str, album_guid: str, owner_dsid: str, creation_date: str, sharing_type: str = "owned", allow_contributions: bool = False, is_public: bool = False, is_web_upload_supported: bool = False, public_url: Optional[str] = None, page_size: int = 100, ) -> None: super().__init__( library=library, name=name, list_type="sharedstream", page_size=page_size, asset_type=PhotoStreamAsset, ) self._album_location: str = album_location self._album_ctag: str = album_ctag self.album_guid: str = album_guid self._owner_dsid: str = owner_dsid try: self.creation_date: datetime = datetime.fromtimestamp( int(creation_date) / 1000.0, timezone.utc ) except ValueError: self.creation_date = datetime.fromtimestamp(0, timezone.utc) # Read only properties self._sharing_type: str = sharing_type self._allow_contributions: bool = allow_contributions self._is_public: bool = is_public self._is_web_upload_supported: bool = is_web_upload_supported self._public_url: Optional[str] = public_url @property def fullname(self) -> str: return self.name @property def sharing_type(self) -> str: """Gets the sharing type.""" return self._sharing_type @property def allow_contributions(self) -> bool: """Gets if contributions are allowed.""" return self._allow_contributions @property def is_public(self) -> bool: """Gets if the album is public.""" return self._is_public @property def is_web_upload_supported(self) -> bool: """Gets if web uploads are supported.""" return self._is_web_upload_supported @property def public_url(self) -> Optional[str]: """Gets the public URL.""" return self._public_url def _get_payload( self, offset: int, page_size: int, direction: str ) -> dict[str, str]: return { "albumguid": self.album_guid, "albumctag": self._album_ctag, "limit": str(min(offset + page_size, len(self))), "offset": str(offset), } def _get_url(self) -> str: return f"{self._album_location}webgetassets?{urlencode(self.service.params)}" def _get_len(self) -> int: url: str = ( f"{self._album_location}webgetassetcount?{urlencode(self.service.params)}" ) request: Response = self.service.session.post( url, data=json.dumps({"albumguid": self.album_guid}), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response: dict[str, Any] = request.json() return response["albumassetcount"] class PhotoAsset: """A photo.""" def __init__( self, service: PhotosService, master_record: dict[str, Any], asset_record: dict[str, Any], ) -> None: self._service: PhotosService = service self._master_record: dict[str, Any] = master_record self._asset_record: dict[str, Any] = asset_record self._versions: Optional[dict[str, dict[str, Any]]] = None ITEM_TYPES: dict[str, str] = { "public.heic": "image", "public.jpeg": "image", "public.png": "image", "com.apple.quicktime-movie": "movie", } PHOTO_VERSION_LOOKUP: dict[str, str] = { "original": "resOriginal", "medium": "resJPEGMed", "thumb": "resJPEGThumb", } VIDEO_VERSION_LOOKUP: dict[str, str] = { "original": "resOriginal", "medium": "resVidMed", "thumb": "resVidSmall", } @property def id(self) -> str: """Gets the photo id.""" return self._master_record["recordName"] @property def filename(self) -> str: """Gets the photo file name.""" return base64.b64decode( self._master_record["fields"]["filenameEnc"]["value"] ).decode("utf-8") @property def size(self): """Gets the photo size.""" return self._master_record["fields"]["resOriginalRes"]["value"]["size"] @property def created(self) -> datetime: """Gets the photo created date.""" return self.asset_date @property def asset_date(self) -> datetime: """Gets the photo asset date.""" try: return datetime.fromtimestamp( self._asset_record["fields"]["assetDate"]["value"] / 1000.0, timezone.utc, ) except KeyError: return datetime.fromtimestamp(0, timezone.utc) @property def added_date(self) -> datetime: """Gets the photo added date.""" return datetime.fromtimestamp( self._asset_record["fields"]["addedDate"]["value"] / 1000.0, timezone.utc ) @property def dimensions(self): """Gets the photo dimensions.""" return ( self._master_record["fields"]["resOriginalWidth"]["value"], self._master_record["fields"]["resOriginalHeight"]["value"], ) @property def item_type(self) -> str: """Gets the photo item type.""" try: item_type: str = self._master_record["fields"]["itemType"]["value"] except KeyError: try: item_type = self._master_record["fields"]["resOriginalFileType"][ "value" ] except KeyError: return "image" if item_type in self.ITEM_TYPES: return self.ITEM_TYPES[item_type] if self.filename.lower().endswith((".heic", ".png", ".jpg", ".jpeg")): return "image" return "movie" @property def versions(self) -> dict[str, dict[str, Any]]: """Gets the photo versions.""" if not self._versions: self._versions = {} if self.item_type == "movie": typed_version_lookup: dict[str, str] = self.VIDEO_VERSION_LOOKUP else: typed_version_lookup = self.PHOTO_VERSION_LOOKUP for key, prefix in typed_version_lookup.items(): if f"{prefix}Res" in self._master_record["fields"]: self._versions[key] = self._get_photo_version(prefix) return self._versions def _get_photo_version(self, prefix: str) -> dict[str, Any]: version: dict = {"filename": self.filename} fields: dict[str, dict[str, Any]] = self._master_record["fields"] width_entry: Optional[dict[str, Any]] = fields.get(f"{prefix}Width") if width_entry: version["width"] = width_entry["value"] else: version["width"] = None height_entry: Optional[dict[str, Any]] = fields.get(f"{prefix}Height") if height_entry: version["height"] = height_entry["value"] else: version["height"] = None size_entry: Optional[dict[str, Any]] = fields.get(f"{prefix}Res") if size_entry: version["size"] = size_entry["value"]["size"] version["url"] = size_entry["value"]["downloadURL"] else: version["size"] = None version["url"] = None type_entry: Optional[dict[str, Any]] = fields.get(f"{prefix}FileType") if type_entry: version["type"] = type_entry["value"] else: version["type"] = None return version def download(self, version="original", **kwargs) -> Optional[Response]: """Returns the photo file.""" if version not in self.versions: return None return self._service.session.get( self.versions[version]["url"], stream=True, **kwargs ) def delete(self) -> Response: """Deletes the photo.""" data: dict[str, Any] = { "operations": [ { "operationType": "update", "record": { "recordName": self._asset_record["recordName"], "recordType": self._asset_record["recordType"], "recordChangeTag": self._master_record["recordChangeTag"], "fields": {"isDeleted": {"value": 1}}, }, } ], "zoneID": self._asset_record["zoneID"], "atomic": True, } endpoint: str = self._service.service_endpoint params: str = urlencode(self._service.params) url: str = f"{endpoint}/records/modify?{params}" return self._service.session.post( url, data=json.dumps(data), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT} ) def __repr__(self) -> str: return f"<{type(self).__name__}: id={self.id}>" class PhotoStreamAsset(PhotoAsset): """A Shared Stream Photo Asset""" @property def like_count(self) -> int: """Gets the photo like count.""" return ( self._asset_record.get("pluginFields", {}) .get("likeCount", {}) .get("value", 0) ) @property def liked(self) -> bool: """Gets if the photo is liked.""" return bool( self._asset_record.get("pluginFields", {}) .get("likedByCaller", {}) .get("value", False) ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/reminders.py0000644000175100001660000001014315023360704020761 0ustar00runnerdocker"""Reminders service.""" import json import time import uuid from datetime import datetime from typing import Any from tzlocal import get_localzone_name from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession class RemindersService(BaseService): """The 'Reminders' iCloud service.""" def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: super().__init__(service_root, session, params) self.lists = {} self.collections = {} self.refresh() def refresh(self) -> None: """Refresh data.""" params_reminders = dict(self.params) params_reminders.update( { "clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name(), } ) # Open reminders req = self.session.get( f"{self.service_root}/rd/startup", params=params_reminders ) data = req.json() self.lists = {} self.collections = {} for collection in data["Collections"]: temp = [] self.collections[collection["title"]] = { "guid": collection["guid"], "ctag": collection["ctag"], } for reminder in data["Reminders"]: if reminder["pGuid"] != collection["guid"]: continue if reminder.get("dueDate"): due = datetime( reminder["dueDate"][0], reminder["dueDate"][1], reminder["dueDate"][2], reminder["dueDate"][3], reminder["dueDate"][4], reminder["dueDate"][5], ) else: due = None temp.append( { "title": reminder["title"], "desc": reminder.get("description"), "due": due, } ) self.lists[collection["title"]] = temp def post(self, title, description="", collection=None, due_date=None): """Adds a new reminder.""" pguid = "tasks" if collection and collection in self.collections: pguid = self.collections[collection]["guid"] params_reminders = dict(self.params) params_reminders.update( {"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()} ) due_dates = None if due_date: due_dates = [ int(f"{due_date.year}{due_date.month:02}{due_date.day:02}"), due_date.year, due_date.month, due_date.day, due_date.hour, due_date.minute, ] req = self.session.post( f"{self.service_root}/rd/reminders/tasks", data=json.dumps( { "Reminders": { "title": title, "description": description, "pGuid": pguid, "etag": None, "order": None, "priority": 0, "recurrence": None, "alarms": [], "startDate": None, "startDateTz": None, "startDateIsAllDay": False, "completedDate": None, "dueDate": due_dates, "dueDateIsAllDay": False, "lastModifiedDate": None, "createdDate": None, "isFamily": None, "createdDateExtended": int(time.time() * 1000), "guid": str(uuid.uuid4()), }, "ClientState": {"Collections": list(self.collections.values())}, } ), params=params_reminders, ) return req.ok ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/services/ubiquity.py0000644000175100001660000001001215023360704020637 0ustar00runnerdocker"""File service.""" from datetime import datetime from typing import Any, Optional from requests import Response from pyicloud.exceptions import PyiCloudAPIResponseException, PyiCloudServiceUnavailable from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession class UbiquityService(BaseService): """The 'Ubiquity' iCloud service.""" def __init__( self, service_root: str, session: PyiCloudSession, params: dict[str, Any] ) -> None: super().__init__(service_root, session, params) self._root: Optional["UbiquityNode"] = None try: self.root except PyiCloudAPIResponseException as error: if error.code == 503: raise PyiCloudServiceUnavailable(error.reason) from error raise @property def root(self) -> "UbiquityNode": """Gets the root node.""" if not self._root: self._root = self.get_node(0) return self._root def get_node_url(self, node_id, variant="item") -> str: """Returns a node URL.""" return f"{self.service_root}/ws/{self.params['dsid']}/{variant}/{node_id}" def get_node(self, node_id) -> "UbiquityNode": """Returns a node.""" response: Response = self.session.get(self.get_node_url(node_id)) return UbiquityNode(self, response.json()) def get_children(self, node_id) -> list["UbiquityNode"]: """Returns a node children.""" response: Response = self.session.get(self.get_node_url(node_id, "parent")) items: list[dict[str, str]] = response.json()["item_list"] return [UbiquityNode(self, item) for item in items] def get_file(self, node_id, **kwargs) -> Response: """Returns a node file.""" return self.session.get(self.get_node_url(node_id, "file"), **kwargs) def __getattr__(self, attr): return getattr(self.root, attr) def __getitem__(self, key) -> "UbiquityNode": return self.root[key] class UbiquityNode: """Ubiquity node.""" def __init__(self, conn: UbiquityService, data: dict[str, str]) -> None: self.data: dict[str, str] = data self.connection: UbiquityService = conn self._children: Optional[list[UbiquityNode]] = None @property def item_id(self) -> Optional[str]: """Gets the node id.""" return self.data.get("item_id") @property def name(self) -> str: """Gets the node name.""" return self.data.get("name", "") @property def type(self) -> str: """Gets the node type.""" return self.data.get("type", "") @property def size(self) -> Optional[int]: """Gets the node size.""" try: return int(self.data.get("size", "-1")) except ValueError: return None @property def modified(self) -> datetime: """Gets the node modified date.""" return datetime.strptime(self.data.get("modified", ""), "%Y-%m-%dT%H:%M:%SZ") def open(self, **kwargs) -> Response: """Returns the node file.""" return self.connection.get_file(self.item_id, **kwargs) def get_children(self) -> list["UbiquityNode"]: """Returns the node children.""" if not self._children: self._children = self.connection.get_children(self.item_id) return self._children def dir(self) -> list[str]: """Returns children node directories by their names.""" return [child.name for child in self.get_children()] def get(self, name: str) -> "UbiquityNode": """Returns a child node by its name.""" return [child for child in self.get_children() if child.name == name][0] def __getitem__(self, key: str) -> "UbiquityNode": try: return self.get(key) except IndexError as i: raise KeyError(f"No child named {key} exists") from i def __str__(self) -> str: return self.name def __repr__(self) -> str: return f"<{self.type.capitalize()}: '{self}'>" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/session.py0000644000175100001660000002505715023360704016643 0ustar00runnerdocker"""Pyicloud Session handling""" import http.cookiejar import logging import os import os.path as path from json import JSONDecodeError, dump, load from re import match from typing import Any, NoReturn, Optional, Union, cast import requests import requests.cookies from requests.models import Response from pyicloud.const import ( CONTENT_TYPE, CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_JSON, ERROR_ACCESS_DENIED, ERROR_AUTHENTICATION_FAILED, ERROR_ZONE_NOT_FOUND, HEADER_DATA, ) from pyicloud.exceptions import ( PyiCloud2FARequiredException, PyiCloud2SARequiredException, PyiCloudAPIResponseException, PyiCloudServiceNotActivatedException, ) KEY_RETRIED = "retried" class PyiCloudCookieJar( requests.cookies.RequestsCookieJar, http.cookiejar.LWPCookieJar ): """Mix the Requests CookieJar with the LWPCookieJar to allow persistance""" class PyiCloudSession(requests.Session): """iCloud session.""" def __init__( self, service, client_id: str, cookie_directory: str, verify: bool = False, headers: Optional[dict[str, str]] = None, ) -> None: super().__init__() self._service = service self.verify = verify self._cookie_directory: str = cookie_directory self.cookies = PyiCloudCookieJar(self.cookiejar_path) self._data: dict[str, Any] = {} self._logger: logging.Logger = logging.getLogger(__name__) if headers: self.headers.update(headers) self._load_session_data() if not self._data.get("client_id"): self._data.update({"client_id": client_id}) @property def data(self) -> dict[str, Any]: """Gets the session data""" return self._data @property def logger(self) -> logging.Logger: """Gets the request logger""" if ( self.service.password_filter is not None and self.service.password_filter not in self._logger.filters ): self._logger.addFilter(self.service.password_filter) return self._logger def _load_session_data(self) -> None: """Load session_data from file.""" if os.path.exists(self.cookiejar_path): cast(PyiCloudCookieJar, self.cookies).load( ignore_discard=True, ignore_expires=True ) self._logger.debug("Using session file %s", self.session_path) self._data: dict[str, Any] = {} try: with open(self.session_path, encoding="utf-8") as session_f: self._data = load(session_f) except ( JSONDecodeError, OSError, ): self._logger.info("Session file does not exist") def _save_session_data(self) -> None: """Save session_data to file.""" with open(self.session_path, "w", encoding="utf-8") as outfile: dump(self._data, outfile) self.logger.debug("Saved session data to file: %s", self.session_path) cast(PyiCloudCookieJar, self.cookies).save( ignore_discard=True, ignore_expires=True ) self.logger.debug("Saved cookies data to file: %s", self.cookiejar_path) def _update_session_data(self, response: Response) -> None: """Update session_data with new data.""" for header, value in HEADER_DATA.items(): if response.headers.get(header): session_arg: str = value self._data.update({session_arg: response.headers.get(header)}) def _is_json_response(self, response: Response) -> bool: content_type: str = response.headers.get(CONTENT_TYPE, "") json_mimetypes: list[str] = [ CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_JSON, ] return content_type.split(";")[0] in json_mimetypes def _reauthenticate_find_my_iphone(self, response: Response) -> None: self.logger.debug("Re-authenticating Find My iPhone service") try: service: Optional[str] = None if response.status_code == 450 else "find" self.service.authenticate(True, service) except PyiCloudAPIResponseException: self.logger.debug("Re-authentication failed") def request( self, method, url, params=None, data=None, headers=None, cookies=None, files=None, auth=None, timeout=None, allow_redirects=True, proxies=None, hooks=None, stream=None, verify=None, cert=None, json=None, ) -> Response: return self._request( method, url, params=params, data=data, headers=headers, cookies=cookies, files=files, auth=auth, timeout=timeout, allow_redirects=allow_redirects, proxies=proxies, hooks=hooks, stream=stream, verify=verify, cert=cert, json=json, ) def _request( self, method, url, *, data=None, has_retried: bool = False, **kwargs, ) -> Response: """Request method.""" self.logger.debug( "%s %s %s", method, url, data or "", ) try: response: Response = super().request( method=method, url=url, data=data, **kwargs, ) self._update_session_data(response) self._save_session_data() if not response.ok and ( self._is_json_response(response) or response.status_code in [421, 450, 500] ): return self._handle_request_error( response=response, method=method, url=url, data=data, has_retried=has_retried, **kwargs, ) response.raise_for_status() if not self._is_json_response(response): return response self._decode_json_response(response) return response except requests.HTTPError as err: raise PyiCloudAPIResponseException( reason=err.response.text, code=err.response.status_code, ) from err except requests.exceptions.RequestException as err: raise PyiCloudAPIResponseException("Request failed to iCloud") from err def _handle_request_error( self, response: Response, method, url, *, data=None, has_retried: bool = False, **kwargs, ) -> Response: """Handle request error.""" if ( response.status_code == 409 and self._is_json_response(response) and (response.json().get("authType") == "hsa2") ): raise PyiCloud2FARequiredException( apple_id=self.service.account_name, response=response, ) try: fmip_url: str = self.service.get_webservice_url("findme") if ( not has_retried and response.status_code in [421, 450, 500] and fmip_url in url ): self._reauthenticate_find_my_iphone(response) return self._request( method=method, url=url, data=data, has_retried=True, **kwargs, ) except PyiCloudServiceNotActivatedException: pass if not has_retried and response.status_code in [421, 450, 500]: return self._request( method=method, url=url, data=data, has_retried=True, **kwargs, ) self._raise_error(response.status_code, response.reason) def _decode_json_response(self, response: Response) -> None: """Decode JSON response.""" if len(response.content) == 0: return try: data: Union[list[dict[str, Any]], dict[str, Any]] = response.json() if isinstance(data, dict): reason: Optional[str] = data.get("errorMessage") reason = reason or data.get("reason") reason = reason or data.get("errorReason") reason = reason or data.get("error") if reason and not isinstance(reason, str): reason = "Unknown reason" if reason: code: Optional[Union[int, str]] = data.get("errorCode") code = code or data.get("serverErrorCode") self._raise_error(code, reason) except JSONDecodeError: self.logger.warning( "Failed to parse response with JSON mimetype: %s", response.text ) def _raise_error(self, code: Optional[Union[int, str]], reason: str) -> NoReturn: if ( self.service.requires_2sa and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie" ): raise PyiCloud2SARequiredException(self.service.account_name) if code in (ERROR_ZONE_NOT_FOUND, ERROR_AUTHENTICATION_FAILED): reason = ( "Please log into https://icloud.com/ to manually " "finish setting up your iCloud service" ) raise PyiCloudServiceNotActivatedException(reason, code) if code == ERROR_ACCESS_DENIED: reason = ( reason + ". Please wait a few minutes then try again." "The remote servers might be trying to throttle requests." ) if code in [421, 450, 500]: reason = "Authentication required for Account." raise PyiCloudAPIResponseException(reason, code) @property def service(self): """Gets the service.""" return self._service @property def cookiejar_path(self) -> str: """Get path for cookiejar file.""" return path.join( self._cookie_directory, "".join([c for c in self.service.account_name if match(r"\w", c)]) + ".cookiejar", ) @property def session_path(self) -> str: """Get path for session data file.""" return path.join( self._cookie_directory, "".join([c for c in self.service.account_name if match(r"\w", c)]) + ".session", ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyicloud/utils.py0000644000175100001660000000314515023360704016312 0ustar00runnerdocker"""Utils.""" import getpass import sys from typing import Optional import keyring KEYRING_SYSTEM = "pyicloud://icloud-password" def get_password(username: str, interactive=sys.stdout.isatty()) -> Optional[str]: """Get the password from a username. Returns the password if found in keyring or if interactive is True. Returns None if no password is found and interactive is False.""" result: Optional[str] = get_password_from_keyring(username) if result: return result if interactive: return getpass.getpass(f"Enter iCloud password for {username}: ") def password_exists_in_keyring(username: str) -> bool: """Return true if the password of a username exists in the keyring.""" return get_password_from_keyring(username) is not None def get_password_from_keyring(username: str) -> Optional[str]: """Get the password from a username.""" return keyring.get_password(KEYRING_SYSTEM, username) def store_password_in_keyring(username: str, password: str) -> None: """Store the password of a username.""" return keyring.set_password( KEYRING_SYSTEM, username, password, ) def delete_password_in_keyring(username: str) -> None: """Delete the password of a username.""" return keyring.delete_password( KEYRING_SYSTEM, username, ) def underscore_to_camelcase(word: str, initial_capital: bool = False) -> str: """Transform a word to camelCase.""" words: list[str] = [x.capitalize() or "_" for x in word.split("_")] if not initial_capital: words[0] = words[0].lower() return "".join(words) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1749934535.616579 pyicloud-2.0.1/pyicloud.egg-info/0000755000175100001660000000000015023360710016264 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/PKG-INFO0000644000175100001660000006256715023360707017407 0ustar00runnerdockerMetadata-Version: 2.4 Name: pyicloud Version: 2.0.1 Summary: PyiCloud is a module which allows pythonistas to interact with iCloud webservices. Author: The PyiCloud Authors Project-URL: homepage, https://github.com/timlaing/pyicloud Project-URL: download, https://github.com/timlaing/pyicloud/releases/latest Project-URL: bug_tracker, https://github.com/timlaing/pyicloud/issues Project-URL: repository, https://github.com/timlaing/pyicloud Keywords: icloud,find-my-iphone Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Software Development :: Libraries Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: certifi>=2024.12.14 Requires-Dist: click>=8.1.8 Requires-Dist: fido2<2.0.0,>=1.2.0 Requires-Dist: keyring>=25.6.0 Requires-Dist: keyrings.alt>=5.0.2 Requires-Dist: requests>=2.31.0 Requires-Dist: srp>=1.0.21 Requires-Dist: tzlocal==5.3.1 Provides-Extra: test Requires-Dist: isort>=5.11.5; extra == "test" Requires-Dist: pre-commit>=2.21.0; extra == "test" Requires-Dist: pylint>=3.3.4; extra == "test" Requires-Dist: pylint-strict-informational>=0.1; extra == "test" Requires-Dist: pytest>=8.3.5; extra == "test" Requires-Dist: pytest-cov>=4.1.0; extra == "test" Requires-Dist: pytest-socket>=0.6.0; extra == "test" Requires-Dist: ruff>=0.9.9; extra == "test" Dynamic: license-file # pyiCloud ![Build Status](https://github.com/timlaing/pyicloud/actions/workflows/tests.yml/badge.svg) [![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) [![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) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=bugs)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=timlaing_pyicloud&metric=coverage)](https://sonarcloud.io/summary/new_code?id=timlaing_pyicloud) [![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 [requests](https://github.com/kennethreitz/requests) HTTP library. At its core, PyiCloud connects to iCloud using your username and password, then performs calendar and iPhone queries against their API. For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/YFvV8nbk) ## Authentication Authentication without using a saved password is as simple as passing your username and password to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password') ``` In the event that the username/password combination is invalid, a `PyiCloudFailedLoginException` exception is thrown. If the country/region setting of your Apple ID is China mainland, you should pass `china_mainland=True` to the `PyiCloudService` class: ``` python from pyicloud import PyiCloudService api = PyiCloudService('jappleseed@apple.com', 'password', china_mainland=True) ``` You can also store your password in the system keyring using the command-line tool: ``` console $ icloud --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. ``` 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: ``` console $ icloud --username=jappleseed@apple.com --delete-from-keyring ``` **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. ### Two-step and two-factor authentication (2SA/2FA) If you have enabled two-factor authentications (2FA) or [two-step authentication (2SA)](https://support.apple.com/en-us/HT204152) for the account you will have to do some extra work: ``` python if api.requires_2fa: security_key_names = api.security_key_names if security_key_names: print( f"Security key confirmation is required. " f"Please plug in one of the following keys: {', '.join(security_key_names)}" ) devices = api.fido2_devices print("Available FIDO2 devices:") for idx, dev in enumerate(devices, start=1): print(f"{idx}: {dev}") choice = click.prompt( "Select a FIDO2 device by number", type=click.IntRange(1, len(devices)), default=1, ) selected_device = devices[choice - 1] print("Please confirm the action using the security key") api.confirm_security_key(selected_device) else: print("Two-factor authentication required.") code = input( "Enter the code you received of one of your approved devices: " ) result = api.validate_2fa_code(code) print("Code validation result: %s" % result) if not result: print("Failed to verify security code") sys.exit(1) if not api.is_trusted_session: print("Session is not trusted. Requesting trust...") result = api.trust_session() print("Session trust result %s" % result) if not result: print( "Failed to request trust. You will likely be prompted for confirmation again in the coming weeks" ) elif api.requires_2sa: import click print("Two-step authentication required. Your trusted devices are:") devices = api.trusted_devices for i, device in enumerate(devices): print( " %s: %s" % (i, device.get('deviceName', "SMS to %s" % device.get('phoneNumber'))) ) device = click.prompt('Which device would you like to use?', default=0) device = devices[device] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) code = click.prompt('Please enter validation code') if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) ``` ## Account You can access information about your iCloud account using the `account` property: ``` pycon >>> api.account {devices: 5, family: 3, storage: 8990635296 bytes free} ``` ### Summary Plan you can access information about your iCloud account\'s summary plan using the `account.summary_plan` property: ``` pycon >>> api.account.summary_plan {'featureKey': 'cloud.storage', 'summary': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAccountPurchasedPlan': {'includedInPlan': True, 'limit': 50, 'limitUnits': 'GIB'}, 'includedWithAppleOnePlan': {'includedInPlan': False}, 'includedWithSharedPlan': {'includedInPlan': False}, 'includedWithCompedPlan': {'includedInPlan': False}, 'includedWithManagedPlan': {'includedInPlan': False}} ``` ### Storage You can get the storage information of your iCloud account using the `account.storage` property: ``` pycon >>> api.account.storage {usage: 85.12% used of 53687091200 bytes, usages_by_media: {'photos': , 'backup': , 'docs': , 'mail': , 'messages': }} ``` You even can generate a pie chart: ``` python ...... storage = api.account.storage y = [] colors = [] labels = [] for usage in storage.usages_by_media.values(): y.append(usage.usage_in_bytes) colors.append(f"#{usage.color}") labels.append(usage.label) plt.pie(y, labels=labels, colors=colors, ) plt.title("Storage Pie Test") plt.show() ``` ## Devices You can list which devices associated with your account by using the `devices` property: ``` pycon >>> api.devices { 'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': , 'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': } ``` and you can access individual devices by either their index, or their ID: ``` pycon >>> api.devices[0] >>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] ``` or, as a shorthand if you have only one associated apple device, you can simply use the `iphone` property to access the first device associated with your account: ``` pycon >>> api.iphone ``` Note: the first device associated with your account may not necessarily be your iPhone. ## Find My iPhone Once you have successfully authenticated, you can start querying your data! ### Location Returns the device\'s last known location. The Find My iPhone app must have been installed and initialized. ``` pycon >>> api.iphone.location() {'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0} ``` ### Status The Find My iPhone response is quite bloated, so for simplicity\'s sake this method will return a subset of the properties. ``` pycon >>> api.iphone.status() {'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"} ``` If you wish to request further properties, you may do so by passing in a list of property names. ### Play Sound Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg. ``` python api.iphone.play_sound() ``` A few moments later, the device will play a ringtone, display the default notification (\"Find My iPhone Alert\") and a confirmation email will be sent to you. ### Lost Mode Lost mode is slightly different to the \"Play Sound\" functionality in that it allows the person who picks up the phone to call a specific phone number *without having to enter the passcode*. Just like \"Play Sound\" you may pass a custom message which the device will display, if it\'s not overridden the custom message of \"This iPhone has been lost. Please call me.\" is used. ``` python phone_number = '555-373-383' message = 'Thief! Return my phone immediately.' api.iphone.lost_device(phone_number, message) ``` ## Calendar The calendar webservice now supports fethcing, creating, and removing calendars and events. ### Calendars The calendar functionality is based around the `CalendarObject` dataclass. Every variable has a default value named according to the http payload parameters from the icloud API. The `guid` is a uuid4 identifier unique to each calendar. The class will create one automatically if it is left blank when the `CalendarObject` is instanced. the `guid` parameter should only be set when you know the guid of an existing calendar. The color is an rgb hex value and will be a random color if not set. #### Functions **get_calendars(as_objs:bool=False) -> list**
*returns a list of the user's calendars*
if `as_objs` is set to `True`, the returned list will be of CalendarObjects; else it will be of dictionaries. **add_calendar(calendar:CalendarObject) -> None:**
*adds a calendar to the users apple calendar* **remove_calendar(cal_guid:str) -> None**
*Removes a Calendar from the apple calendar given the provided guid* #### Examples *Create and add a new calendar:* ``` python api = login("username", "pass") calendar_service = api.calendar cal = calendar_service.CalendarObject(title="My Calendar", share_type="published") cal.color = "#FF0000" calendar_service.add_calendar(cal) ``` *Remove an existing calendar:* ``` python cal = calendar_service.get_calendars(as_objs=True)[1] calendar_service.remove_calendar(cal.guid) ``` ### Events The events functionality is based around the `EventObject` dataclass. `guid` is the unique identifier of each event, while `pGuid` is the identifier of the calendar to which this event belongs. `pGuid` is the only paramter that is not optional. Some of the functionality of Events, most notably Alarms, is not included here, but could be easily done had you the desire. The `EventObject` currently has one method you may use: `add_invitees` which takes a list of emails and adds them as invitees to this event. They should recieve an email when this event is created. #### Functions **get_events(from_dt:datetime=None, to_dt:datetime=None, period:str="month", as_objs:bool=False)**
*Returns a list of events from `from_dt` to `to_dt`. If `period` is provided, it will return the events in that period refrencing `from_dt` if it was provided; else using today's date. IE if `period` is "month", the events for the entire month that `from_dt` falls within will be returned.* **get_event_detail(pguid, guid, as_obj:bool=False)**
*Returns a speciffic event given that event's `guid` and `pGuid`* **add_event(event:EventObject) -> None**
*Adds an Event to a calendar specified by the event's `pGuid`.* **remove_event(event:EventObject) -> None**
*Removes an Event from a calendar specified by the event's `pGuid`.* #### Examples *Create, add and remove an Event* ``` python calendar_service = api.calendar cal = calendar_service.get_calendars(as_objs=True)[0] event = EventObject(cal.guid, title="test", start_date=datetime.today(), end_date=datetime.today() + timedelta(hours=1)) calendar_service.add_event(event) calendar_service.remove_event(event) ``` Or, between a specific date range: ``` python from_dt = datetime(2012, 1, 1) to_dt = datetime(2012, 1, 31) api.calendar.events(from_dt, to_dt) ``` *Get next weeks' events* ``` python calendar_service.get_events(from_dt=datetime.today() + timedelta(days=7) ,period="week", as_objs=True) ``` ## Contacts 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')) John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}] ``` Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud. ### MeCard You can access the user's info (contact information) using the `me` property: ``` pycon >>> api.contacts.me Tim Cook ``` And get the user's profile picture: ``` pycon >>> api.contacts.me.photo {'signature': 'the signature', 'url': 'URL to the picture', 'crop': {'x': 0, 'width': 640, 'y': 110, 'height': 640}} ``` ## File Storage (Ubiquity) You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: ``` pycon >>> api.files.dir() ['.do-not-delete', '.localized', 'com~apple~Notes', 'com~apple~Preview', 'com~apple~mail', 'com~apple~shoebox', 'com~apple~system~spotlight' ] ``` You can access children and their children\'s children using the filename as an index: ``` pycon >>> api.files['com~apple~Notes'] >>> api.files['com~apple~Notes'].type 'folder' >>> api.files['com~apple~Notes'].dir() ['Documents'] >>> api.files['com~apple~Notes']['Documents'].dir() ['Some Document'] >>> api.files['com~apple~Notes']['Documents']['Some Document'].name 'Some Document' >>> api.files['com~apple~Notes']['Documents']['Some Document'].modified datetime.datetime(2012, 9, 13, 2, 26, 17) >>> api.files['com~apple~Notes']['Documents']['Some Document'].size 1308134 >>> api.files['com~apple~Notes']['Documents']['Some Document'].type 'file' ``` And when you have a file that you\'d like to download, the `open` method will return a response object from which you can read the `content`. ``` pycon >>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content 'Hello, these are the file contents' ``` Note: the object returned from the above `open` method is a [response object](http://www.python-requests.org/en/latest/api/#classes) and the `open` method can accept any parameters you might normally use in a request using [requests](https://github.com/kennethreitz/requests). For example, if you know that the file you\'re opening has JSON content: ``` pycon >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json() {'How much we love you': 'lots'} >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you'] 'lots' ``` Or, if you\'re downloading a particularly large file, you may want to use the `stream` keyword argument, and read directly from the raw response object: ``` pycon >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True) >>> with open('downloaded_file.zip', 'wb') as opened_file: opened_file.write(download.raw.read()) ``` ## File Storage (iCloud Drive) You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at `api.drive`: ``` pycon >>> api.drive.dir() ['Holiday Photos', 'Work Files'] >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG', 'DSC08117.JPG'] >>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'] >>> drive_file.name 'DSC08116.JPG' >>> drive_file.date_modified datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC >>> drive_file.size 2021698 >>> drive_file.type 'file' ``` The `open` method will return a response object from which you can read the file\'s contents: ``` python from shutil import copyfileobj with drive_file.open(stream=True) as response: with open(drive_file.name, 'wb') as file_out: copyfileobj(response.raw, file_out) ``` To interact with files and directions the `mkdir`, `rename` and `delete` functions are available for a file or folder: ``` python api.drive['Holiday Photos'].mkdir('2020') api.drive['Holiday Photos']['2020'].rename('2020_copy') api.drive['Holiday Photos']['2020_copy'].delete() ``` The `upload` method can be used to send a file-like object to the iCloud Drive: ``` python with open('Vacation.jpeg', 'rb') as file_in: api.drive['Holiday Photos'].upload(file_in) ``` It is strongly suggested to open file handles as binary rather than text to prevent decoding errors further down the line. You can also interact with files in the `trash`: ``` pycon >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'].delete() >>> api.drive.trash.dir() ['DSC08116.JPG'] >>> delete_output = api.drive['Holiday Photos']['2013']['Sicily']['DSC08117.JPG'].delete() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() ['DSC08116.JPG', 'DSC08117.JPG'] ``` You can interact with the `trash` similar to a standard directory, with some restrictions. In addition, files in the `trash` can be recovered back to their original location, or deleted forever: ``` pycon >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() [] >>> recover_output = api.drive.trash['DSC08116.JPG'].recover() >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() ['DSC08116.JPG'] >>> api.drive.trash.dir() ['DSC08117.JPG'] >>> purge_output = api.drive.trash['DSC08117.JPG'].delete_forever() >>> api.drive.refresh_trash() >>> api.drive.trash.dir() [] ``` ## Photo Library You can access the iCloud Photo Library through the `photos` property. ``` pycon >>> api.photos.all ``` Individual albums are available through the `albums` property: ``` pycon >>> api.photos.albums['Screenshots'] ``` Which you can iterate to access the photo assets. The "All Photos" album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) : ``` pycon >>> for photo in api.photos.albums['Screenshots']: print(photo, photo.filename) IMG_6045.JPG ``` To download a photo use the `download` method, which will return a [Response object](https://requests.readthedocs.io/en/latest/api/#requests.Response), initialized with `stream` set to `True`, so you can read from the raw response object: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) download = photo.download() with open(photo.filename, 'wb') as opened_file: opened_file.write(download.raw.read()) ``` Consider using `shutil.copyfileobj` or another buffered strategy for downloading so that the whole file isn't read into memory before writing. ``` python import shutil photo = next(iter(api.photos.albums['Screenshots']), None) response_obj = photo.download() with open(photo.filename, 'wb') as f: shutil.copyfileobj(response_obj.raw, f) ``` Information about each version can be accessed through the `versions` property: ``` pycon >>> photo.versions.keys() ['medium', 'original', 'thumb'] ``` To download a specific version of the photo asset, pass the version to `download()`: ``` python download = photo.download('thumb') with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(download.raw.read()) ``` To upload an image ``` python api.photos.upload_file(file_path) ``` Note: Only limited media type is accepted, upload not support types (e.g. png) will get TYPE_UNSUPPORTED error. ## Hide My Email You can access the iCloud Hide My Email service through the `hidemyemail` property To generate a new email alias use the `generate` method. ```python # Generate a new email alias new_email = api.hidemyemail.generate() print(f"Generated new email: {new_email}") ``` To reserve the generated email with a custom label ```python reserved = api.hidemyemail.reserve(new_email, "Shopping") print(f"Reserved email - response: {reserved}") ``` To get the anonymous_id (unique identifier) from the reservation. ``` python anonymous_id = reserved.get("anonymousId") print(anonymous_id) ``` To list the current aliases ``` python # Print details of each alias for alias in api.hidemyemail: print(f"- {alias.get('hme')}: {alias.get('label')} ({alias.get('anonymousId')})") ``` Additional detail usage ```python # Get detailed information about a specific alias alias_details = api.hidemyemail[anonymous_id] print(f"Alias details: {alias_details}") # Update the alias metadata (label and note) updated = api.hidemyemail.update_metadata( anonymous_id, "Online Shopping", "Used for e-commerce websites" ) print(f"Updated alias: {updated}") # Deactivate an alias (stops email forwarding but keeps the alias for future reactivation) deactivated = api.hidemyemail.deactivate(anonymous_id) print(f"Deactivated alias: {deactivated}") # Reactivate a previously deactivated alias (resumes email forwarding) reactivated = api.hidemyemail.reactivate(anonymous_id) print(f"Reactivated alias: {reactivated}") # Delete the alias when no longer needed deleted = api.hidemyemail.delete(anonymous_id) print(f"Deleted alias: {deleted}") ``` ## Examples If you want to see some code samples, see the [examples](/examples.py). ` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/SOURCES.txt0000644000175100001660000000346215023360707020163 0ustar00runnerdocker.gitignore .pre-commit-config.yaml CODE_SAMPLES.md LICENSE.txt README.md examples.py pylintrc pyproject.toml requirements.txt requirements_all.txt requirements_test.txt sonar-project.properties .devcontainer/Dockerfile .devcontainer/devcontainer.json .github/FUNDING.yml .github/PULL_REQUEST_TEMPLATE.md .github/dependabot.yml .github/release-drafter.yml .github/ISSUE_TEMPLATE/BUG.md .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md .github/ISSUE_TEMPLATE/SUPPORT.md .github/workflows/linting.yml .github/workflows/publish.yml .github/workflows/release-drafter.yml .github/workflows/sonarcube.yml .github/workflows/tests.yml .vscode/launch.json .vscode/settings.json .vscode/tasks.json pyicloud/__init__.py pyicloud/base.py pyicloud/cmdline.py pyicloud/const.py pyicloud/exceptions.py pyicloud/session.py pyicloud/utils.py pyicloud.egg-info/PKG-INFO pyicloud.egg-info/SOURCES.txt pyicloud.egg-info/dependency_links.txt pyicloud.egg-info/entry_points.txt pyicloud.egg-info/requires.txt pyicloud.egg-info/top_level.txt pyicloud/.vscode/launch.json pyicloud/services/__init__.py pyicloud/services/account.py pyicloud/services/base.py pyicloud/services/calendar.py pyicloud/services/contacts.py pyicloud/services/drive.py pyicloud/services/findmyiphone.py pyicloud/services/hidemyemail.py pyicloud/services/photos.py pyicloud/services/reminders.py pyicloud/services/ubiquity.py scripts/build.sh scripts/pyenv.sh scripts/setup.sh tests/__init__.py tests/conftest.py tests/const.py tests/const_account.py tests/const_account_family.py tests/const_drive.py tests/const_findmyiphone.py tests/const_login.py tests/test_account.py tests/test_base.py tests/test_calendar.py tests/test_cmdline.py tests/test_contacts.py tests/test_drive.py tests/test_findmyiphone.py tests/test_hidemyemail.py tests/test_photos.py tests/test_reminders.py tests/test_ubiquity.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/dependency_links.txt0000644000175100001660000000000115023360707022340 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/entry_points.txt0000644000175100001660000000006115023360707021565 0ustar00runnerdocker[console_scripts] icloud = pyicloud.cmdline:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/requires.txt0000644000175100001660000000043615023360707020675 0ustar00runnerdockercertifi>=2024.12.14 click>=8.1.8 fido2<2.0.0,>=1.2.0 keyring>=25.6.0 keyrings.alt>=5.0.2 requests>=2.31.0 srp>=1.0.21 tzlocal==5.3.1 [test] isort>=5.11.5 pre-commit>=2.21.0 pylint>=3.3.4 pylint-strict-informational>=0.1 pytest>=8.3.5 pytest-cov>=4.1.0 pytest-socket>=0.6.0 ruff>=0.9.9 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934535.0 pyicloud-2.0.1/pyicloud.egg-info/top_level.txt0000644000175100001660000000001115023360707021014 0ustar00runnerdockerpyicloud ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pylintrc0000644000175100001660000000237215023360704014540 0ustar00runnerdocker[MASTER] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs=2 persistent=no extension-pkg-whitelist=ciso8601 [BASIC] good-names=id,i,j,k [MESSAGES CONTROL] # Reasons disabled: # format - handled by black # duplicate-code - unavoidable # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # inconsistent-return-statements - doesn't handle raise # unnecessary-pass - readability for functions which only contain pass # useless-object-inheritance - should be removed while droping Python 2 # wrong-import-order - isort guards this # consider-using-f-string - temporarily to be able to not block Python upgrade disable= format, duplicate-code, inconsistent-return-statements, too-few-public-methods, too-many-ancestors, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, too-many-boolean-expressions, unnecessary-pass, useless-object-inheritance, wrong-import-order, consider-using-f-string [FORMAT] expected-line-ending-format=LF [EXCEPTIONS] overgeneral-exceptions=PyiCloudException ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/pyproject.toml0000644000175100001660000000536415023360704015671 0ustar00runnerdocker[tool.ruff] exclude = [ ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".ipynb_checkpoints", ".mypy_cache", ".nox", ".pants.d", ".pyenv", ".pytest_cache", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", ".vscode", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "site-packages", "venv", ] # Same as Black. line-length = 88 indent-width = 4 # Assume Python 3.9 target-version = "py39" [tool.pytest.ini_options] testpaths = ["tests"] norecursedirs = [".git", ".tox", "build", "lib"] addopts = ["--disable-socket", "--allow-unix-socket"] [tool.coverage.run] branch = true omit = [ "examples.py", ] [tool.coverage.paths] source = [ "pyicloud/", "tests/" ] [tool.isort] profile = "black" [tool.tox] requires = ["tox>=4.24.1"] env_list = ["lint", "3.13", "3.12", "3.11", "3.10", "3.9"] [tool.tox.env_run_base] description = "Run test under {base_python}" commands = [["pytest"]] deps = ["-r requirements_test.txt"] [tool.tox.gh-actions] python = ''' 3.9: 3.9, lint 3.10: 3.10 3.11: 3.11 3.12: 3.12 3.13: 3.13 ''' [tool.setuptools_scm] [build-system] requires = ["setuptools >= 77.0,< 80.10", "setuptools-scm"] build-backend = "setuptools.build_meta" [project] name = "pyicloud" dynamic = ["version", "readme", "dependencies", "optional-dependencies"] description = "PyiCloud is a module which allows pythonistas to interact with iCloud webservices." requires-python = ">=3.9" license-files = ["LICENSE.txt"] authors = [ {name = "The PyiCloud Authors"} ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries" ] keywords = ["icloud", "find-my-iphone"] [project.urls] homepage = "https://github.com/timlaing/pyicloud" download = "https://github.com/timlaing/pyicloud/releases/latest" bug_tracker = "https://github.com/timlaing/pyicloud/issues" repository = "https://github.com/timlaing/pyicloud" [project.scripts] icloud = "pyicloud.cmdline:main" [tool.setuptools] packages = ["pyicloud", "pyicloud.services"] [tool.setuptools.dynamic] readme = {file = "README.md", content-type = "text/markdown"} dependencies = {file = ["requirements.txt"]} optional-dependencies = {test = {file = ["requirements_test.txt"]}} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/requirements.txt0000644000175100001660000000020515023360704016226 0ustar00runnerdockercertifi>=2024.12.14 click>=8.1.8 fido2>=1.2.0,<2.0.0 keyring>=25.6.0 keyrings.alt>=5.0.2 requests>=2.31.0 srp>=1.0.21 tzlocal==5.3.1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/requirements_all.txt0000644000175100001660000000007115023360704017057 0ustar00runnerdocker-r requirements.txt -r requirements_test.txt tox==4.26.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/requirements_test.txt0000644000175100001660000000022115023360704017263 0ustar00runnerdockerisort>=5.11.5 pre-commit>=2.21.0 pylint>=3.3.4 pylint-strict-informational>=0.1 pytest>=8.3.5 pytest-cov>=4.1.0 pytest-socket>=0.6.0 ruff>=0.9.9 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6135793 pyicloud-2.0.1/scripts/0000755000175100001660000000000015023360710014431 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/scripts/build.sh0000755000175100001660000000011315023360704016065 0ustar00runnerdocker#!/bin/bash set -euo pipefail mkdir -p dist rm -rf dist/* python -m build ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/scripts/pyenv.sh0000755000175100001660000000036515023360704016140 0ustar00runnerdocker#!/usr/bin/env zsh # Install pyenv curl -fsSL https://pyenv.run | bash export PATH="$HOME/.pyenv/bin:$PATH" export PYENV_ROOT="$HOME/.pyenv" echo 'eval "$(pyenv init -)"' >> ~/.zshrc eval "$(pyenv init -)" pyenv install 3.9 3.10 3.11 3.12 3.13 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/scripts/setup.sh0000755000175100001660000000042715023360704016136 0ustar00runnerdocker#!/usr/bin/zsh set -e uv venv && uv pip install -r requirements_all.txt grep -qxF 'source /workspaces/pyicloud/.venv/bin/activate' ~/.zshrc || cat <>~/.zshrc if [ -f "/workspaces/pyicloud/.venv/bin/activate" ]; then source /workspaces/pyicloud/.venv/bin/activate fi EOF ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749934535.6185791 pyicloud-2.0.1/setup.cfg0000644000175100001660000000004615023360710014563 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/sonar-project.properties0000644000175100001660000000044215023360704017651 0ustar00runnerdockersonar.projectKey=timlaing_pyicloud sonar.organization=timlaing sonar.sources=./pyicloud sonar.tests=./tests sonar.python.coverage.reportPaths=coverage.xml # This is the name and version displayed in the SonarCloud UI. sonar.projectName=pyicloud sonar.python.version=3.9,3.10,3.11,3.12,3.13 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1749934535.616579 pyicloud-2.0.1/tests/0000755000175100001660000000000015023360710014104 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/__init__.py0000644000175100001660000002346315023360704016230 0ustar00runnerdocker"""Library tests.""" import json from io import BytesIO from typing import Any, Optional from requests import Response from pyicloud import base from tests.const import ( AUTHENTICATED_USER, REQUIRES_2FA_TOKEN, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_COOKIE, VALID_TOKEN, VALID_TOKENS, VALID_USERS, ) from tests.const_account import ACCOUNT_DEVICES_WORKING, ACCOUNT_STORAGE_WORKING from tests.const_account_family import ACCOUNT_FAMILY_WORKING from tests.const_drive import ( DRIVE_FILE_DOWNLOAD_WORKING, DRIVE_FOLDER_WORKING, DRIVE_ROOT_INVALID, DRIVE_ROOT_WORKING, DRIVE_SUBFOLDER_WORKING, DRIVE_TRASH_DELETE_FOREVER_WORKING, DRIVE_TRASH_RECOVER_WORKING, DRIVE_TRASH_WORKING, ) from tests.const_findmyiphone import FMI_FAMILY_WORKING from tests.const_login import ( AUTH_OK, LOGIN_2FA, LOGIN_WORKING, TRUSTED_DEVICE_1, TRUSTED_DEVICES, VERIFICATION_CODE_KO, VERIFICATION_CODE_OK, ) class ResponseMock(Response): """Mocked Response.""" def __init__(self, result, status_code=200, **kwargs) -> None: """Set up response mock.""" Response.__init__(self) self.result = result self.status_code = status_code self.raw = kwargs.get("raw") self.headers = kwargs.get("headers", {}) @property def text(self) -> str: """Return text.""" return json.dumps(self.result) class PyiCloudSessionMock(base.PyiCloudSession): """Mocked PyiCloudSession.""" def _request(self, method, url, **kwargs) -> ResponseMock: """Make the request.""" params = kwargs.get("params") headers = kwargs.get("headers") data = kwargs.get("json") if not data: data = json.loads(kwargs.get("data", "{}")) if kwargs.get("data") else {} if self._service.setup_endpoint in url: if resp := self._handle_setup_endpoint(url, method, data, headers): return resp if self._service.auth_endpoint in url: if resp := self._handle_auth_endpoint(url, method, data): return resp if resp := self._handle_other_endpoints(url, method, data, params): return resp raise ValueError("No valid response") def _handle_other_endpoints( self, url, method, data, params ) -> Optional[ResponseMock]: """Handle other endpoints.""" if "device/getDevices" in url and method == "GET": return ResponseMock(ACCOUNT_DEVICES_WORKING) if "family/getFamilyDetails" in url and method == "GET": return ResponseMock(ACCOUNT_FAMILY_WORKING) if "setup/ws/1/storageUsageInfo" in url and method == "POST": return ResponseMock(ACCOUNT_STORAGE_WORKING) resp: Optional[ResponseMock] = None resp = self._handle_drive_endpoints_post(url, method, data) if resp: return resp resp = self._handle_drive_endpoints_get(url, method, params) if resp: return resp if "fmi" in url and method == "POST": return ResponseMock(FMI_FAMILY_WORKING) def _handle_drive_endpoints_post(self, url, method, data) -> Optional[ResponseMock]: """Handle drive endpoints post requests.""" if "retrieveItemDetailsInFolders" in url and method == "POST": if resp := self._handle_drive_retrieve(data): return resp if "putBackItemsFromTrash" in url and method == "POST": if resp := self._handle_drive_trash_recover(data): return resp if "deleteItems" in url and method == "POST": if resp := self._handle_drive_trash_delete(data): return resp def _handle_drive_endpoints_get( self, url, method, params ) -> Optional[ResponseMock]: """Handle drive endpoints get requests.""" if "com.apple.CloudDocs/download/by_id" in url and method == "GET" and params: if resp := self._handle_drive_download(params): return resp if "icloud-content.com" in url and method == "GET": if resp := self._handle_icloud_content(url): return resp def _handle_setup_endpoint( self, url, method, data, headers ) -> Optional[ResponseMock]: """Handle setup endpoint requests.""" if "accountLogin" in url and method == "POST": return self._handle_account_login(data) if "listDevices" in url and method == "GET": return ResponseMock(TRUSTED_DEVICES) if "sendVerificationCode" in url and method == "POST": return self._handle_send_verification_code(data) if "validateVerificationCode" in url and method == "POST": return self._handle_validate_verification_code(data) if "validate" in url and method == "POST" and headers: return self._handle_validate(headers) def _handle_auth_endpoint(self, url, method, data) -> Optional[ResponseMock]: """Handle auth endpoint requests.""" if "signin" in url and method == "POST": return self._handle_signin(data) if "securitycode" in url and method == "POST": return self._handle_security_code(data) if "trust" in url and method == "GET": return ResponseMock("", status_code=204) def _handle_account_login(self, data: dict[str, Any]) -> ResponseMock: """Handle account login.""" if data.get("dsWebAuthToken") not in VALID_TOKENS: self._raise_error(None, "Unknown reason") if data.get("dsWebAuthToken") == REQUIRES_2FA_TOKEN: return ResponseMock(LOGIN_2FA) return ResponseMock(LOGIN_WORKING) def _handle_send_verification_code(self, data: dict[str, Any]) -> ResponseMock: """Handle send verification code.""" if data == TRUSTED_DEVICE_1: return ResponseMock(VERIFICATION_CODE_OK) return ResponseMock(VERIFICATION_CODE_KO) def _handle_validate_verification_code(self, data: dict[str, Any]) -> ResponseMock: """Handle validate verification code.""" TRUSTED_DEVICE_1.update( { "verificationCode": "0", "trustBrowser": True, } ) if data == TRUSTED_DEVICE_1: self._service._apple_id = AUTHENTICATED_USER # pylint: disable=protected-access return ResponseMock(VERIFICATION_CODE_OK) self._raise_error(None, "FOUND_CODE") def _handle_validate(self, headers: dict[str, Any]) -> ResponseMock: """Handle validate.""" if headers.get("X-APPLE-WEBAUTH-TOKEN") == VALID_COOKIE: return ResponseMock(LOGIN_WORKING) self._raise_error(None, "Session expired") def _handle_signin(self, data: dict[str, Any]) -> ResponseMock: """Handle signin.""" if data.get("accountName") not in VALID_USERS: self._raise_error(None, "Unknown reason") if data.get("accountName") == REQUIRES_2FA_USER: self._service.session._data["session_token"] = REQUIRES_2FA_TOKEN # pylint: disable=protected-access return ResponseMock(AUTH_OK) self._service.session._data["session_token"] = VALID_TOKEN # pylint: disable=protected-access return ResponseMock(AUTH_OK) def _handle_security_code(self, data: dict[str, Any]) -> ResponseMock: """Handle security code.""" if data.get("securityCode", {}).get("code") != VALID_2FA_CODE: self._raise_error(None, "Incorrect code") self._service.session._data["session_token"] = VALID_TOKEN # pylint: disable=protected-access return ResponseMock("", status_code=204) def _handle_drive_retrieve(self, data: dict[Any, Any]) -> Optional[ResponseMock]: """Handle drive retrieve item details.""" drivewsid = data[0].get("drivewsid") if drivewsid == "FOLDER::com.apple.CloudDocs::root": return ResponseMock(DRIVE_ROOT_WORKING) if drivewsid == "FOLDER::com.apple.Preview::documents": return ResponseMock(DRIVE_ROOT_INVALID) if drivewsid == "FOLDER::com.apple.CloudDocs::TRASH_ROOT": return ResponseMock(DRIVE_TRASH_WORKING) if ( drivewsid == "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B" ): return ResponseMock(DRIVE_FOLDER_WORKING) if ( drivewsid == "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF" ): return ResponseMock(DRIVE_SUBFOLDER_WORKING) def _handle_drive_trash_recover( self, data: dict[str, Any] ) -> Optional[ResponseMock]: """Handle drive trash recover.""" items_data = data.get("items") if ( items_data and items_data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::2BF8600B-5DCC-4421-805A-1C28D07197D5" ): return ResponseMock(DRIVE_TRASH_RECOVER_WORKING) def _handle_drive_trash_delete( self, data: dict[str, Any] ) -> Optional[ResponseMock]: """Handle drive trash delete forever.""" items_data = data.get("items") if ( items_data and items_data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::478AEA23-42A2-468A-ABC1-1A04BC07F738" ): return ResponseMock(DRIVE_TRASH_DELETE_FOREVER_WORKING) def _handle_drive_download(self, params: dict[str, Any]) -> Optional[ResponseMock]: """Handle drive download.""" if params.get("document_id") == "516C896C-6AA5-4A30-B30E-5502C2333DAE": return ResponseMock(DRIVE_FILE_DOWNLOAD_WORKING) def _handle_icloud_content(self, url: str) -> Optional[ResponseMock]: """Handle iCloud content.""" if "Scanned+document+1.pdf" in url: return ResponseMock({}, raw=BytesIO(b"PDF_CONTENT")) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/conftest.py0000644000175100001660000001153315023360704016311 0ustar00runnerdocker"""Pytest configuration file for the pyicloud package.""" import os import secrets from unittest.mock import MagicMock, mock_open, patch import pytest from requests.cookies import RequestsCookieJar from pyicloud.base import PyiCloudService from pyicloud.services.contacts import ContactsService from pyicloud.services.drive import COOKIE_APPLE_WEBAUTH_VALIDATE from pyicloud.services.hidemyemail import HideMyEmailService from pyicloud.session import PyiCloudSession from tests import PyiCloudSessionMock from tests.const_login import LOGIN_WORKING BUILTINS_OPEN: str = "builtins.open" EXAMPLE_DOMAIN: str = "https://example.com" class FileSystemAccessError(Exception): """Raised when a test tries to access the file system.""" @pytest.fixture(autouse=True, scope="function") def mock_mkdir(): """Mock the mkdir function to prevent file system access.""" mkdir = os.mkdir def my_mkdir(path, *args, **kwargs): if "python-test-results" not in path: raise FileSystemAccessError( f"You should not be creating directories in tests. {path}" ) return mkdir(path, *args, **kwargs) with patch("os.mkdir", my_mkdir) as mkdir_mock: yield mkdir_mock @pytest.fixture(autouse=True, scope="session") def mock_open_fixture(): """Mock the open function to prevent file system access.""" builtins_open = open def my_open(path, *args, **kwargs): if "python-test-results" not in path: raise FileSystemAccessError( f"You should not be opening files in tests. {path}" ) return builtins_open(path, *args, **kwargs) with patch(BUILTINS_OPEN, my_open) as open_mock: yield open_mock @pytest.fixture def pyicloud_service() -> PyiCloudService: """Create a PyiCloudService instance with mocked authenticate method.""" with ( patch("pyicloud.base.PyiCloudService.authenticate") as mock_authenticate, patch(BUILTINS_OPEN, new_callable=mock_open), ): # Mock the authenticate method during initialization mock_authenticate.return_value = None service = PyiCloudService("test@example.com", secrets.token_hex(32)) return service @pytest.fixture def pyicloud_service_working(pyicloud_service: PyiCloudService) -> PyiCloudService: # pylint: disable=redefined-outer-name """Set the service to a working state.""" pyicloud_service.data = LOGIN_WORKING pyicloud_service._webservices = LOGIN_WORKING["webservices"] # pylint: disable=protected-access with patch(BUILTINS_OPEN, new_callable=mock_open): pyicloud_service.session = PyiCloudSessionMock( pyicloud_service, "", cookie_directory="", ) pyicloud_service.session._data = {"session_token": "valid_token"} # pylint: disable=protected-access return pyicloud_service @pytest.fixture def pyicloud_session(pyicloud_service_working: PyiCloudService) -> PyiCloudSession: # pylint: disable=redefined-outer-name """Mock the PyiCloudSession class.""" pyicloud_service_working.session.cookies = MagicMock() return pyicloud_service_working.session @pytest.fixture def mock_session() -> MagicMock: """Fixture to create a mock PyiCloudSession.""" return MagicMock(spec=PyiCloudSession) @pytest.fixture def contacts_service(mock_session: MagicMock) -> ContactsService: # pylint: disable=redefined-outer-name """Fixture to create a ContactsService instance.""" return ContactsService( service_root=EXAMPLE_DOMAIN, session=mock_session, params={"test_param": "value"}, ) @pytest.fixture def mock_photos_service() -> MagicMock: """Fixture for mocking PhotosService.""" service = MagicMock() service.service_endpoint = EXAMPLE_DOMAIN service.params = {"dsid": "12345"} service.session = MagicMock() return service @pytest.fixture def mock_photo_library(mock_photos_service: MagicMock) -> MagicMock: # pylint: disable=redefined-outer-name """Fixture for mocking PhotoLibrary.""" library = MagicMock() library.service = mock_photos_service return library @pytest.fixture def hidemyemail_service(mock_session: MagicMock) -> HideMyEmailService: # pylint: disable=redefined-outer-name """Fixture for initializing HideMyEmailService.""" return HideMyEmailService(EXAMPLE_DOMAIN, mock_session, {"dsid": "12345"}) @pytest.fixture def mock_service_with_cookies( pyicloud_service_working: PyiCloudService, # pylint: disable=redefined-outer-name ) -> PyiCloudService: """Fixture to create a mock PyiCloudService with cookies.""" jar = RequestsCookieJar() jar.set(COOKIE_APPLE_WEBAUTH_VALIDATE, "t=768y9u", domain="icloud.com", path="/") # Attach a real CookieJar so code that calls `.cookies.get()` keeps working. pyicloud_service_working.session.cookies = jar return pyicloud_service_working ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const.py0000644000175100001660000000100115023360704015577 0ustar00runnerdocker"""Test constants.""" from tests.const_account_family import APPLE_ID_EMAIL, ICLOUD_ID_EMAIL, PRIMARY_EMAIL # Base AUTHENTICATED_USER = PRIMARY_EMAIL REQUIRES_2FA_TOKEN = "requires_2fa_token" REQUIRES_2FA_USER = "requires_2fa_user" VALID_USERS = [AUTHENTICATED_USER, REQUIRES_2FA_USER, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL] VALID_PASSWORD = "valid_password" VALID_COOKIE = "valid_cookie" VALID_TOKEN = "valid_token" VALID_2FA_CODE = "000000" VALID_TOKENS = [VALID_TOKEN, REQUIRES_2FA_TOKEN] CLIENT_ID = "client_id" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const_account.py0000644000175100001660000001046715023360704017333 0ustar00runnerdocker"""Account test constants.""" from tests.const_login import FIRST_NAME # Fakers PAYMENT_METHOD_ID_1 = "PAYMENT_METHOD_ID_1" PAYMENT_METHOD_ID_2 = "PAYMENT_METHOD_ID_2" PAYMENT_METHOD_ID_3 = "PAYMENT_METHOD_ID_3" PAYMENT_METHOD_ID_4 = "PAYMENT_METHOD_ID_4" # Data ACCOUNT_DEVICES_WORKING = { "devices": [ { "serialNumber": "●●●●●●●NG123", "osVersion": "OSX;10.15.3", "modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox__2x.png", "modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox.png", "paymentMethods": [PAYMENT_METHOD_ID_3], "name": "MacBook Pro de " + FIRST_NAME, "imei": "", "model": "MacBookPro15,1", "udid": "MacBookPro15,1" + FIRST_NAME, "modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist__2x.png", "modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist.png", "modelDisplayName": 'MacBook Pro 15"', }, { "serialNumber": "●●●●●●●UX123", "osVersion": "iOS;13.3", "modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox__2x.png", "modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox.png", "paymentMethods": [ PAYMENT_METHOD_ID_4, PAYMENT_METHOD_ID_2, PAYMENT_METHOD_ID_1, ], "name": "iPhone de " + FIRST_NAME, "imei": "●●●●●●●●●●12345", "model": "iPhone12,1", "udid": "iPhone12,1" + FIRST_NAME, "modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist__2x.png", "modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist.png", "modelDisplayName": "iPhone 11", }, ], "paymentMethods": [ { "lastFourDigits": "333", "balanceStatus": "NOTAPPLICABLE", "suspensionReason": "ACTIVE", "id": PAYMENT_METHOD_ID_3, "type": "Boursorama Banque", }, { "lastFourDigits": "444", "balanceStatus": "NOTAPPLICABLE", "suspensionReason": "ACTIVE", "id": PAYMENT_METHOD_ID_4, "type": "Carte Crédit Agricole", }, { "lastFourDigits": "2222", "balanceStatus": "NOTAPPLICABLE", "suspensionReason": "ACTIVE", "id": PAYMENT_METHOD_ID_2, "type": "Lydia", }, { "lastFourDigits": "111", "balanceStatus": "NOTAPPLICABLE", "suspensionReason": "ACTIVE", "id": PAYMENT_METHOD_ID_1, "type": "Boursorama Banque", }, ], } ACCOUNT_STORAGE_WORKING = { "storageUsageByMedia": [ { "mediaKey": "photos", "displayLabel": "Photos et vidéos", "displayColor": "ffcc00", "usageInBytes": 0, }, { "mediaKey": "backup", "displayLabel": "Sauvegarde", "displayColor": "5856d6", "usageInBytes": 799008186, }, { "mediaKey": "docs", "displayLabel": "Documents", "displayColor": "ff9500", "usageInBytes": 449092146, }, { "mediaKey": "mail", "displayLabel": "Mail", "displayColor": "007aff", "usageInBytes": 1101522944, }, ], "storageUsageInfo": { "compStorageInBytes": 0, "usedStorageInBytes": 2348632876, "totalStorageInBytes": 5368709120, "commerceStorageInBytes": 0, }, "quotaStatus": { "overQuota": False, "haveMaxQuotaTier": False, "almost-full": False, "paidQuota": False, }, } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const_account_family.py0000644000175100001660000000663415023360704020675 0ustar00runnerdocker"""Account family test constants.""" # Fakers FIRST_NAME = "Quentin" LAST_NAME = "TARANTINO" FULL_NAME = FIRST_NAME + " " + LAST_NAME PERSON_ID = (FIRST_NAME + LAST_NAME).lower() PRIMARY_EMAIL = PERSON_ID + "@hotmail.fr" APPLE_ID_EMAIL = PERSON_ID + "@me.com" ICLOUD_ID_EMAIL = PERSON_ID + "@icloud.com" MEMBER_1_FIRST_NAME = "John" MEMBER_1_LAST_NAME = "TRAVOLTA" MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" MEMBER_2_FIRST_NAME = "Uma" MEMBER_2_LAST_NAME = "THURMAN" MEMBER_2_FULL_NAME = MEMBER_2_FIRST_NAME + " " + MEMBER_2_LAST_NAME MEMBER_2_PERSON_ID = (MEMBER_2_FIRST_NAME + MEMBER_2_LAST_NAME).lower() MEMBER_2_APPLE_ID = MEMBER_2_PERSON_ID + "@outlook.fr" FAMILY_ID = "family_" + PERSON_ID # Data ACCOUNT_FAMILY_WORKING = { "status-message": "Member of a family.", "familyInvitations": [], "outgoingTransferRequests": [], "isMemberOfFamily": True, "family": { "familyId": FAMILY_ID, "transferRequests": [], "invitations": [], "organizer": PERSON_ID, "members": [PERSON_ID, MEMBER_2_PERSON_ID, MEMBER_1_PERSON_ID], "outgoingTransferRequests": [], "etag": "12", }, "familyMembers": [ { "lastName": LAST_NAME, "dsid": PERSON_ID, "originalInvitationEmail": PRIMARY_EMAIL, "fullName": FULL_NAME, "ageClassification": "ADULT", "appleIdForPurchases": PRIMARY_EMAIL, "appleId": PRIMARY_EMAIL, "familyId": FAMILY_ID, "firstName": FIRST_NAME, "hasParentalPrivileges": True, "hasScreenTimeEnabled": False, "hasAskToBuyEnabled": False, "hasSharePurchasesEnabled": True, "shareMyLocationEnabledFamilyMembers": [], "hasShareMyLocationEnabled": True, "dsidForPurchases": PERSON_ID, }, { "lastName": MEMBER_2_LAST_NAME, "dsid": MEMBER_2_PERSON_ID, "originalInvitationEmail": MEMBER_2_APPLE_ID, "fullName": MEMBER_2_FULL_NAME, "ageClassification": "ADULT", "appleIdForPurchases": MEMBER_2_APPLE_ID, "appleId": MEMBER_2_APPLE_ID, "familyId": FAMILY_ID, "firstName": MEMBER_2_FIRST_NAME, "hasParentalPrivileges": False, "hasScreenTimeEnabled": False, "hasAskToBuyEnabled": False, "hasSharePurchasesEnabled": False, "hasShareMyLocationEnabled": False, "dsidForPurchases": MEMBER_2_PERSON_ID, }, { "lastName": MEMBER_1_LAST_NAME, "dsid": MEMBER_1_PERSON_ID, "originalInvitationEmail": MEMBER_1_APPLE_ID, "fullName": MEMBER_1_FULL_NAME, "ageClassification": "ADULT", "appleIdForPurchases": MEMBER_1_APPLE_ID, "appleId": MEMBER_1_APPLE_ID, "familyId": FAMILY_ID, "firstName": MEMBER_1_FIRST_NAME, "hasParentalPrivileges": False, "hasScreenTimeEnabled": False, "hasAskToBuyEnabled": False, "hasSharePurchasesEnabled": True, "hasShareMyLocationEnabled": True, "dsidForPurchases": MEMBER_1_PERSON_ID, }, ], "status": 0, "showAddMemberButton": True, } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const_drive.py0000644000175100001660000007211415023360704017005 0ustar00runnerdocker"""Drive test constants.""" DRIVEWSID = "FOLDER::com.apple.CloudDocs::root" ZONE = "com.apple.CloudDocs" DATE_CREATED = "2019-12-12T14:33:55-08:00" FOLDER1 = "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B" FOLDER2 = "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF" # Data DRIVE_ROOT_WORKING = [ { "drivewsid": DRIVEWSID, "docwsid": "root", "zone": ZONE, "name": "", "etag": "31", "type": "FOLDER", "assetQuota": 62418076, "fileCount": 7, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 3, "items": [ { "dateCreated": DATE_CREATED, "drivewsid": "FOLDER::com.apple.Keynote::documents", "docwsid": "documents", "zone": "com.apple.Keynote", "name": "Keynote", "parentId": DRIVEWSID, "etag": "2m", "type": "APP_LIBRARY", "maxDepth": "ANY", "icons": [ { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon120x120_iOS", "type": "IOS", "size": 120, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon80x80_iOS", "type": "IOS", "size": 80, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon40x40_iOS", "type": "IOS", "size": 40, }, ], "supportedExtensions": [ "pptx", "ppsx", "pps", "pot", "key-tef", "ppt", "potx", "potm", "pptm", "ppsm", "key", "kth", ], "supportedTypes": [ "com.microsoft.powerpoint.pps", "com.microsoft.powerpoint.pot", "com.microsoft.powerpoint.ppt", "org.openxmlformats.presentationml.template.macroenabled", "org.openxmlformats.presentationml.slideshow.macroenabled", "com.apple.iwork.keynote.key-tef", "org.openxmlformats.presentationml.template", "org.openxmlformats.presentationml.presentation.macroenabled", "com.apple.iwork.keynote.key", "com.apple.iwork.keynote.kth", "org.openxmlformats.presentationml.presentation", "org.openxmlformats.presentationml.slideshow", "com.apple.iwork.keynote.sffkey", "com.apple.iwork.keynote.sffkth", ], }, { "dateCreated": DATE_CREATED, "drivewsid": "FOLDER::com.apple.Numbers::documents", "docwsid": "documents", "zone": "com.apple.Numbers", "name": "Numbers", "parentId": DRIVEWSID, "etag": "3k", "type": "APP_LIBRARY", "maxDepth": "ANY", "icons": [ { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon120x120_iOS", "type": "IOS", "size": 120, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon80x80_iOS", "type": "IOS", "size": 80, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon40x40_iOS", "type": "IOS", "size": 40, }, ], "supportedExtensions": [ "hh", "ksh", "lm", "xlt", "c++", "f95", "lid", "csv", "numbers", "php4", "hp", "py", "nmbtemplate", "lmm", "jscript", "php3", "crash", "patch", "java", "ym", "xlam", "text", "mi", "exp", "adb", "jav", "ada", "ii", "defs", "mm", "cpp", "cxx", "pas", "diff", "pch++", "javascript", "panic", "rb", "ads", "tcsh", "ypp", "yxx", "ph3", "ph4", "phtml", "xltx", "hang", "rbw", "f77", "for", "js", "h++", "mig", "gpurestart", "mii", "zsh", "m3u", "pch", "sh", "xltm", "applescript", "tsv", "ymm", "shutdownstall", "cc", "xlsx", "scpt", "c", "inl", "f", "numbers-tef", "h", "i", "hpp", "hxx", "dlyan", "xla", "l", "cp", "m", "lpp", "lxx", "txt", "r", "s", "xlsm", "spin", "php", "csh", "y", "bash", "m3u8", "pl", "f90", "pm", "xls", ], "supportedTypes": [ "org.openxmlformats.spreadsheetml.sheet", "com.microsoft.excel.xla", "com.apple.iwork.numbers.template", "org.openxmlformats.spreadsheetml.sheet.macroenabled", "com.apple.iwork.numbers.sffnumbers", "com.apple.iwork.numbers.numbers", "public.plain-text", "com.microsoft.excel.xlt", "org.openxmlformats.spreadsheetml.template", "com.microsoft.excel.xls", "public.comma-separated-values-text", "com.apple.iwork.numbers.numbers-tef", "org.openxmlformats.spreadsheetml.template.macroenabled", "public.tab-separated-values-text", "com.apple.iwork.numbers.sfftemplate", "com.microsoft.excel.openxml.addin", ], }, { "dateCreated": DATE_CREATED, "drivewsid": "FOLDER::com.apple.Pages::documents", "docwsid": "documents", "zone": "com.apple.Pages", "name": "Pages", "parentId": DRIVEWSID, "etag": "km", "type": "APP_LIBRARY", "maxDepth": "ANY", "icons": [ { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon120x120_iOS", "type": "IOS", "size": 120, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon80x80_iOS", "type": "IOS", "size": 80, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon40x40_iOS", "type": "IOS", "size": 40, }, ], "supportedExtensions": [ "hh", "ksh", "lm", "c++", "f95", "lid", "php4", "hp", "py", "lmm", "jscript", "php3", "crash", "patch", "pages", "java", "ym", "text", "mi", "exp", "adb", "jav", "ada", "ii", "defs", "mm", "cpp", "cxx", "pas", "pages-tef", "diff", "pch++", "javascript", "panic", "rb", "ads", "tcsh", "rtfd", "ypp", "yxx", "doc", "ph3", "ph4", "template", "phtml", "hang", "rbw", "f77", "dot", "for", "js", "h++", "mig", "gpurestart", "mii", "zsh", "m3u", "pch", "sh", "applescript", "ymm", "shutdownstall", "dotx", "cc", "scpt", "c", "rtf", "inl", "f", "h", "i", "hpp", "hxx", "dlyan", "l", "cp", "m", "lpp", "lxx", "docx", "txt", "r", "s", "spin", "php", "csh", "y", "bash", "m3u8", "pl", "f90", "pm", ], "supportedTypes": [ "com.apple.rtfd", "com.apple.iwork.pages.sffpages", "com.apple.iwork.pages.sfftemplate", "com.microsoft.word.dot", "com.apple.iwork.pages.pages", "com.microsoft.word.doc", "org.openxmlformats.wordprocessingml.template", "org.openxmlformats.wordprocessingml.document", "com.apple.iwork.pages.pages-tef", "com.apple.iwork.pages.template", "public.rtf", "public.plain-text", ], }, { "dateCreated": DATE_CREATED, "drivewsid": "FOLDER::com.apple.Preview::documents", "docwsid": "documents", "zone": "com.apple.Preview", "name": "Preview", "parentId": DRIVEWSID, "etag": "bv", "type": "APP_LIBRARY", "maxDepth": "ANY", "icons": [ { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon32x32_OSX", "type": "OSX", "size": 32, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon128x128_OSX", "type": "OSX", "size": 128, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon16x16_OSX", "type": "OSX", "size": 16, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon256x256_OSX", "type": "OSX", "size": 256, }, { "url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon64x64_OSX", "type": "OSX", "size": 64, }, ], "supportedExtensions": [ "ps", "nmbtemplate", "astc", "mpkg", "prefpane", "pef", "mos", "qlgenerator", "scptd", "raf", "saver", "band", "dng", "pict", "exr", "kth", "appex", "app", "pages-tef", "slidesaver", "pluginkit", "distz", "ai", "png", "eps", "raw", "pvr", "mpo", "ktx", "nrw", "lpdf", "pfm", "3fr", "template", "imovielibrary", "pwl", "iwwebpackage", "wdgt", "tga", "pgm", "erf", "jpeg", "j2c", "bundle", "key", "j2k", "abc", "arw", "xpc", "pic", "ppm", "menu", "icns", "mrw", "plugin", "mdimporter", "bmp", "numbers", "dae", "dist", "pic", "rw2", "nef", "tif", "pages", "sgi", "ico", "theater", "gbproj", "webplugin", "cr2", "fff", "webp", "jp2", "sr2", "rtfd", "pbm", "pkpass", "jfx", "fpbf", "psd", "xbm", "tiff", "avchd", "gif", "pntg", "rwl", "pset", "pkg", "dcr", "hdr", "jpe", "pct", "jpg", "jpf", "orf", "srf", "numbers-tef", "iconset", "crw", "fpx", "dds", "pdf", "jpx", "key-tef", "efx", "hdr", "srw", ], "supportedTypes": [ "com.adobe.illustrator.ai-image", "com.kodak.flashpix-image", "public.pbm", "com.apple.pict", "com.ilm.openexr-image", "com.sgi.sgi-image", "com.apple.icns", "public.heifs", "com.truevision.tga-image", "com.adobe.postscript", "public.camera-raw-image", "public.pvr", "public.png", "com.adobe.photoshop-image", "public.heif", "com.microsoft.ico", "com.adobe.pdf", "public.heic", "public.xbitmap-image", "com.apple.localized-pdf-bundle", "public.3d-content", "com.compuserve.gif", "public.avci", "public.jpeg", "com.apple.rjpeg", "com.adobe.encapsulated-postscript", "com.microsoft.bmp", "public.fax", "org.khronos.astc", "com.apple.application-bundle", "public.avcs", "public.webp", "public.heics", "com.apple.macpaint-image", "public.mpo-image", "public.jpeg-2000", "public.tiff", "com.microsoft.dds", "com.apple.pdf-printer-settings", "org.khronos.ktx", "public.radiance", "com.apple.package", "public.folder", ], }, { "drivewsid": FOLDER1, "docwsid": "1C7F1760-D940-480F-8C4F-005824A4E05B", "zone": ZONE, "name": "pyiCloud", "parentId": DRIVEWSID, "etag": "30", "type": "FOLDER", "assetQuota": 42199575, "fileCount": 2, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 1, }, ], "numberOfItems": 5, } ] # App specific folder (Keynote, Numbers, Pages, Preview ...) type=APP_LIBRARY DRIVE_ROOT_INVALID = [ {"drivewsid": "FOLDER::com.apple.CloudDocs::documents", "status": "ID_INVALID"} ] DRIVE_FOLDER_WORKING = [ { "drivewsid": FOLDER1, "docwsid": "1C7F1760-D940-480F-8C4F-005824A4E05B", "zone": ZONE, "name": "pyiCloud", "parentId": DRIVEWSID, "etag": "30", "type": "FOLDER", "assetQuota": 42199575, "fileCount": 2, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 1, "items": [ { "drivewsid": FOLDER2, "docwsid": "D5AA0425-E84F-4501-AF5D-60F1D92648CF", "zone": ZONE, "name": "Test", "parentId": FOLDER1, "etag": "2z", "type": "FOLDER", "assetQuota": 42199575, "fileCount": 2, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 2, } ], "numberOfItems": 1, } ] DRIVE_SUBFOLDER_WORKING = [ { "drivewsid": FOLDER2, "docwsid": "D5AA0425-E84F-4501-AF5D-60F1D92648CF", "zone": ZONE, "name": "Test", "parentId": FOLDER1, "etag": "2z", "type": "FOLDER", "assetQuota": 42199575, "fileCount": 2, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 2, "items": [ { "drivewsid": "FILE::com.apple.CloudDocs::33A41112-4131-4938-9691-7F356CE3C51D", "docwsid": "33A41112-4131-4938-9691-7F356CE3C51D", "zone": ZONE, "name": "Document scanné 2", "parentId": FOLDER2, "dateModified": "2020-04-27T21:37:36Z", "dateChanged": "2020-04-27T14:44:29-07:00", "size": 19876991, "etag": "2k::2j", "extension": "pdf", "hiddenExtension": True, "lastOpenTime": "2020-04-27T21:37:36Z", "type": "FILE", }, { "drivewsid": "FILE::com.apple.CloudDocs::516C896C-6AA5-4A30-B30E-5502C2333DAE", "docwsid": "516C896C-6AA5-4A30-B30E-5502C2333DAE", "zone": ZONE, "name": "Scanned document 1", "parentId": FOLDER2, "dateModified": "2020-05-03T00:15:17Z", "dateChanged": "2020-05-02T17:16:17-07:00", "size": 21644358, "etag": "32::2x", "extension": "pdf", "hiddenExtension": True, "lastOpenTime": "2020-05-03T00:24:25Z", "type": "FILE", }, ], "numberOfItems": 2, } ] DRIVE_FILE_DOWNLOAD_WORKING = { "document_id": "516C896C-6AA5-4A30-B30E-5502C2333DAE", "data_token": { "url": "https://cvws.icloud-content.com/B/signature1ref_signature1/Scanned+document+1.pdf?o=object1&v=1&x=3&a=token1&e=1588472097&k=wrapping_key1&fl=&r=request&ckc=com.apple.clouddocs&ckz=com.apple.CloudDocs&p=31&s=s1", "token": "token1", "signature": "signature1", "wrapping_key": "wrapping_key1==", "reference_signature": "ref_signature1", }, "thumbnail_token": { "url": "https://cvws.icloud-content.com/B/signature2ref_signature2/Scanned+document+1.jpg?o=object2&v=1&x=3&a=token2&e=1588472097&k=wrapping_key2&fl=&r=request&ckc=com.apple.clouddocs&ckz=com.apple.CloudDocs&p=31&s=s2", "token": "token2", "signature": "signature2", "wrapping_key": "wrapping_key2==", "reference_signature": "ref_signature2", }, "double_etag": "32::2x", } DRIVE_TRASH_WORKING = [ { "items": [ { "dateCreated": "2022-06-23T20:58:35Z", "drivewsid": "FILE::com.apple.CloudDocs::C2AD01E4-E625-47FE-AE83-4DF311A05A48", "docwsid": "C2AD01E4-E625-47FE-AE83-4DF311A05A48", "zone": ZONE, "name": "dead-file", "extension": "download", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T02:17:55Z", "isChainedToParent": True, "dateModified": "2022-06-23T20:43:02Z", "dateChanged": "2024-11-12T02:17:55Z", "size": 11364977, "etag": "o72::o6y", "restorePath": "Downloads/dead-file.download", "lastOpenTime": "2024-11-12T02:15:18Z", "type": "FILE", }, { "dateCreated": "2024-11-12T04:41:18Z", "drivewsid": "FOLDER::com.apple.CloudDocs::31102B37-D62F-4322-862C-EDE2030C8AFA", "docwsid": "31102B37-D62F-4322-862C-EDE2030C8AFA", "zone": ZONE, "name": "test_create_folder", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T04:48:22Z", "isChainedToParent": True, "restorePath": "test_create_folder", "etag": "o96", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, }, { "dateCreated": "2024-11-12T04:18:13Z", "drivewsid": "FOLDER::com.apple.CloudDocs::478AEA23-42A2-468A-ABC1-1A04BC07F738", "docwsid": "478AEA23-42A2-468A-ABC1-1A04BC07F738", "zone": ZONE, "name": "test_delete_forever_and_ever", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T04:18:20Z", "isChainedToParent": True, "restorePath": "test_delete_forever_and_ever", "etag": "o8h", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, }, { "dateCreated": "2024-11-12T03:41:18Z", "drivewsid": "FOLDER::com.apple.CloudDocs::E63A9193-4428-4AE1-A334-83B880C75379", "docwsid": "E63A9193-4428-4AE1-A334-83B880C75379", "zone": ZONE, "name": "test_files_1", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T03:42:07Z", "isChainedToParent": True, "restorePath": "test_files_1", "etag": "o7s", "type": "FOLDER", "assetQuota": 7, "fileCount": 1, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 1, }, { "dateCreated": "2024-11-12T03:37:13Z", "drivewsid": "FOLDER::com.apple.CloudDocs::2BF8600B-5DCC-4421-805A-1C28D07197D5", "docwsid": "2BF8600B-5DCC-4421-805A-1C28D07197D5", "zone": ZONE, "name": "test_random_uuid", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T03:57:30Z", "isChainedToParent": True, "restorePath": "test_random_uuid", "etag": "o9a", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, }, { "dateCreated": "2024-11-12T04:25:27Z", "drivewsid": "FOLDER::com.apple.CloudDocs::B9B90B8D-CCC2-4BDB-A58D-289F746C3478", "docwsid": "B9B90B8D-CCC2-4BDB-A58D-289F746C3478", "zone": ZONE, "name": "test12345", "parentId": "TRASH_ROOT", "dateExpiration": "2024-12-12T04:31:46Z", "isChainedToParent": True, "restorePath": "test12345", "etag": "o8y", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, }, ], "numberOfItems": 6, "drivewsid": "TRASH_ROOT", } ] DRIVE_TRASH_RECOVER_WORKING = { "items": [ { "dateCreated": "2024-11-12T03:37:13Z", "drivewsid": "FOLDER::com.apple.CloudDocs::2BF8600B-5DCC-4421-805A-1C28D07197D5", "docwsid": "2BF8600B-5DCC-4421-805A-1C28D07197D5", "zone": ZONE, "name": "test_random_uuid", "parentId": DRIVEWSID, "isChainedToParent": True, "item_id": "CJC_vaYFEAAiEH8Y2nkmm0bfntz-AmIQWC4", "etag": "o9g", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, "status": "OK", } ] } DRIVE_TRASH_DELETE_FOREVER_WORKING = { "items": [ { "dateCreated": "2024-11-12T04:18:14Z", "drivewsid": "FOLDER::com.apple.CloudDocs::478AEA23-42A2-468A-ABC1-1A04BC07F738", "docwsid": "478AEA23-42A2-468A-ABC1-1A04BC07F738", "zone": ZONE, "name": "test_delete_forever_and_ever", "isDeleted": True, "parentId": "FOLDER::com.apple.CloudDocs::43D7C666-6E6E-4522-8999-0B519C3A1F4B", "dateExpiration": "2024-12-12T04:18:20Z", "isChainedToParent": True, "item_id": "CJqQty4QACIQjiS90WklSeGExLvHPWWruzgB", "restorePath": "test_delete_forever_and_ever", "etag": "null", "type": "FOLDER", "assetQuota": 0, "fileCount": 0, "shareCount": 0, "shareAliasCount": 0, "directChildrenCount": 0, "status": "OK", } ] } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const_findmyiphone.py0000644000175100001660000011165715023360704020373 0ustar00runnerdocker"""Find my iPhone test constants.""" from typing import Any from tests.const import CLIENT_ID from tests.const_account_family import ( FIRST_NAME, FULL_NAME, LAST_NAME, MEMBER_1_APPLE_ID, MEMBER_1_FIRST_NAME, MEMBER_1_LAST_NAME, MEMBER_1_PERSON_ID, MEMBER_2_APPLE_ID, MEMBER_2_FIRST_NAME, MEMBER_2_LAST_NAME, MEMBER_2_PERSON_ID, PERSON_ID, ) # Fakers UUID = "ABCDEFGH-1234-5678-1234-ABCDEFGHIJKL" LOCATION_LATITUDE = 45.123456789012345 LOCATION_LONGITUDE = 6.1234567890123456 IPHONE4_1 = "iPhone4,1" IPHONE12_1 = "iPhone12,1" MACBOOKPRO10_1 = "MacBookPro10,1" IPAD7_3 = "iPad7,3" MACBOOKPRO15_1 = "MacBookPro15,1" MACBOOK_PRO_15 = "MacBook Pro 15" MACBOOK_PRO = "MacBook Pro" # Data # Re-generated device : # id = rawDeviceModel + prsId (if not None) # baUUID = UUID + id # So they can still be faked and unique FMI_FAMILY_WORKING: dict[str, Any] = { "userInfo": { "accountFormatter": 0, "firstName": FIRST_NAME, "lastName": LAST_NAME, "membersInfo": { MEMBER_1_PERSON_ID: { "accountFormatter": 0, "firstName": MEMBER_1_FIRST_NAME, "lastName": MEMBER_1_LAST_NAME, "deviceFetchStatus": "LOADING", "useAuthWidget": True, "isHSA": True, "appleId": MEMBER_1_APPLE_ID, }, MEMBER_2_PERSON_ID: { "accountFormatter": 0, "firstName": MEMBER_2_FIRST_NAME, "lastName": MEMBER_2_LAST_NAME, "deviceFetchStatus": "LOADING", "useAuthWidget": True, "isHSA": True, "appleId": MEMBER_2_APPLE_ID, }, }, "hasMembers": True, }, "serverContext": { "minCallbackIntervalInMS": 5000, "enable2FAFamilyActions": False, "preferredLanguage": "fr-fr", "lastSessionExtensionTime": None, "enableMapStats": True, "callbackIntervalInMS": 2000, "validRegion": True, "timezone": { "currentOffset": -25200000, "previousTransition": 1583661599999, "previousOffset": -28800000, "tzCurrentName": "Pacific Daylight Time", "tzName": "America/Los_Angeles", }, "authToken": None, "maxCallbackIntervalInMS": 60000, "classicUser": False, "isHSA": True, "trackInfoCacheDurationInSecs": 86400, "imageBaseUrl": "https://statici.icloud.com", "minTrackLocThresholdInMts": 100, "maxLocatingTime": 90000, "sessionLifespan": 900000, "info": "info_id", "prefsUpdateTime": 1413548552466, "useAuthWidget": True, "clientId": CLIENT_ID, "enable2FAFamilyRemove": False, "serverTimestamp": 1585867038112, "deviceImageVersion": "4", "macCount": 0, "deviceLoadStatus": "200", "maxDeviceLoadTime": 60000, "prsId": PERSON_ID, "showSllNow": False, "cloudUser": True, "enable2FAErase": False, }, "alert": None, "userPreferences": { "webPrefs": { "id": "web_prefs", "selectedDeviceId": IPHONE4_1, } }, "content": [ { "msg": { "strobe": False, "userText": False, "playSound": True, "vibrate": True, "createTimestamp": 1584520568680, "statusCode": "200", }, "canWipeAfterLock": True, "baUUID": UUID + IPHONE12_1, "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": True, "passcodeLength": 6, "deviceStatus": "200", "deviceColor": "1-6-0", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": False, "LST": True, "LKM": False, "WMG": True, "PSS": False, "PIN": False, "LCK": True, "REM": False, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": True, "rawDeviceModel": IPHONE12_1, "id": IPHONE12_1, "remoteLock": None, "isLocating": True, "modelDisplayName": "iPhone", "lostTimestamp": "", "batteryLevel": 0.8299999833106995, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": {"createTimestamp": 1584520568680, "statusCode": "200"}, "fmlyShare": False, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPhone 11", "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "NotCharging", "trackingInfo": None, "name": "iPhone de " + FIRST_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPhone", "location": { "isOld": False, "isInaccurate": False, "altitude": 0.0, "positionType": "GPS", "latitude": LOCATION_LATITUDE, "floorLevel": 0, "horizontalAccuracy": 4.5370291025030465, "locationType": "", "timeStamp": 1585867037749, "locationFinished": True, "verticalAccuracy": 0.0, "longitude": LOCATION_LONGITUDE, }, "deviceModel": "iphone11-1-6-0", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432463, "statusCode": "205", }, "canWipeAfterLock": True, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 4, "deviceStatus": "203", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": True, "LST": True, "LKM": False, "WMG": False, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": IPHONE4_1, "id": IPHONE4_1, "remoteLock": None, "isLocating": False, "modelDisplayName": "iPhone", "lostTimestamp": "", "batteryLevel": 0.0, "mesg": {"createTimestamp": 1583057432463, "statusCode": "205"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": { "stopLostMode": False, "emailUpdates": True, "userText": True, "sound": False, "ownerNbr": "", "text": "", "createTimestamp": 1463594549526, "statusCode": "2201", }, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPhone 4s", "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "iPhone " + FULL_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPhone", "location": None, "deviceModel": "FifthGen", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432463, "statusCode": "205", }, "canWipeAfterLock": True, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 4, "deviceStatus": "203", "deviceColor": "white", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": True, "LST": True, "LKM": False, "WMG": False, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": "iPod4,1", "id": "iPod4,1", "remoteLock": None, "isLocating": False, "modelDisplayName": "iPod", "lostTimestamp": "", "batteryLevel": 0.0, "mesg": {"createTimestamp": 1583057432463, "statusCode": "205"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPod touch (4th generation)", "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "iPod Touch 4 " + MEMBER_2_FIRST_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPod", "location": None, "deviceModel": "FourthGen-white", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": False, "playSound": True, "vibrate": False, "createTimestamp": 1398963329049, "statusCode": "200", }, "canWipeAfterLock": False, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 4, "deviceStatus": "203", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": True, "LKL": True, "LST": False, "LKM": True, "WMG": False, "PSS": False, "PIN": True, "LCK": True, "REM": True, "MCS": False, "KEY": True, "KPD": True, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": MACBOOKPRO10_1, "id": MACBOOKPRO10_1, "remoteLock": None, "isLocating": False, "modelDisplayName": MACBOOK_PRO, "lostTimestamp": "", "batteryLevel": 0.0, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": {"createTimestamp": 1398963329049, "statusCode": "200"}, "fmlyShare": False, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": MACBOOK_PRO_15, "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "Retina " + MEMBER_2_FIRST_NAME, "isMac": True, "thisDevice": False, "deviceClass": "MacBookPro", "location": None, "deviceModel": "MacBookPro10_1", "maxMsgChar": 500, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432463, "statusCode": "200", }, "canWipeAfterLock": False, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 6, "deviceStatus": "203", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": True, "LKL": True, "LST": False, "LKM": True, "WMG": False, "PSS": False, "PIN": True, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": True, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": "MacBookPro11,3", "id": "MacBookPro11,3", "remoteLock": {"createTimestamp": 1433338956786, "statusCode": "2201"}, "isLocating": False, "modelDisplayName": MACBOOK_PRO, "lostTimestamp": "", "batteryLevel": 0.0, "mesg": {"createTimestamp": 1583057432463, "statusCode": "200"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": MACBOOK_PRO_15, "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "Retina " + FIRST_NAME, "isMac": True, "thisDevice": False, "deviceClass": "MacBookPro", "location": None, "deviceModel": "MacBookPro11_3", "maxMsgChar": 500, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432463, "statusCode": "200", }, "canWipeAfterLock": False, "baUUID": UUID + MACBOOKPRO15_1, "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": True, "passcodeLength": 6, "deviceStatus": "201", "deviceColor": "spacegray", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": True, "LKL": False, "LST": False, "LKM": True, "WMG": False, "PSS": False, "PIN": True, "LCK": True, "REM": True, "MCS": False, "KEY": True, "KPD": True, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": MACBOOKPRO15_1, "id": MACBOOKPRO15_1, "remoteLock": None, "isLocating": False, "modelDisplayName": MACBOOK_PRO, "lostTimestamp": "", "batteryLevel": 0.26968246698379517, "mesg": {"createTimestamp": 1583057432463, "statusCode": "200"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": MACBOOK_PRO_15, "prsId": None, "audioChannels": [], "locationCapable": True, "batteryStatus": "Charging", "trackingInfo": None, "name": "MacBook Pro de " + FIRST_NAME, "isMac": True, "thisDevice": False, "deviceClass": "MacBookPro", "location": { "isOld": False, "isInaccurate": False, "altitude": 0.0, "positionType": "Wifi", "latitude": LOCATION_LATITUDE, "floorLevel": 0, "horizontalAccuracy": 65.0, "locationType": "", "timeStamp": 1585867020040, "locationFinished": False, "verticalAccuracy": 0.0, "longitude": LOCATION_LONGITUDE, }, "deviceModel": "MacBookPro15_1-spacegray", "maxMsgChar": 500, "darkWake": False, "remoteWipe": None, }, { "msg": None, "canWipeAfterLock": False, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 6, "deviceStatus": "200", "deviceColor": "0", "features": { "BTR": True, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": False, "LST": False, "LKM": False, "WMG": False, "PSS": True, "PIN": False, "LCK": False, "REM": False, "MCS": True, "KEY": False, "KPD": False, "WIP": False, }, "lowPowerMode": False, "rawDeviceModel": "AirPods_8207", "id": "AirPods_8207", "remoteLock": None, "isLocating": False, "modelDisplayName": "Accessory", "lostTimestamp": "", "batteryLevel": 0.0, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": "Accessory", "prsId": None, "audioChannels": [ {"name": "left", "available": 1, "playing": False, "muted": False}, {"name": "right", "available": 1, "playing": False, "muted": False}, ], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "AirPods de " + FULL_NAME, "isMac": False, "thisDevice": False, "deviceClass": "Accessory", "location": { "isOld": False, "isInaccurate": False, "altitude": 0.0, "positionType": "GPS", "latitude": LOCATION_LATITUDE, "floorLevel": 0, "horizontalAccuracy": 4.5370291025030465, "locationType": "", "timeStamp": 1585867037749, "locationFinished": True, "verticalAccuracy": 0.0, "longitude": LOCATION_LONGITUDE, }, "deviceModel": "AirPods_8207-0", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": None, "canWipeAfterLock": False, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 4, "deviceStatus": "201", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": True, "LKL": False, "LST": False, "LKM": True, "WMG": False, "PSS": False, "PIN": True, "LCK": True, "REM": True, "MCS": False, "KEY": True, "KPD": True, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": MACBOOKPRO10_1, "id": MACBOOKPRO10_1 + MEMBER_2_PERSON_ID, "remoteLock": None, "isLocating": False, "modelDisplayName": MACBOOK_PRO, "lostTimestamp": "", "batteryLevel": 0.0, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": MACBOOK_PRO_15, "prsId": MEMBER_2_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "MacBook Pro de " + MEMBER_2_FIRST_NAME, "isMac": True, "thisDevice": False, "deviceClass": "MacBookPro", "location": None, "deviceModel": "MacBookPro10_1", "maxMsgChar": 500, "darkWake": False, "remoteWipe": None, }, { "msg": None, "canWipeAfterLock": True, "baUUID": UUID + IPHONE12_1 + MEMBER_2_PERSON_ID, "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": True, "passcodeLength": 6, "deviceStatus": "200", "deviceColor": "1-7-0", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": False, "LST": True, "LKM": False, "WMG": True, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": IPHONE12_1, "id": IPHONE12_1 + MEMBER_2_PERSON_ID, "remoteLock": None, "isLocating": False, "modelDisplayName": "iPhone", "lostTimestamp": "", "batteryLevel": 0.3400000035762787, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": True, "snd": None, "fmlyShare": False, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPhone 11", "prsId": MEMBER_2_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "NotCharging", "trackingInfo": None, "name": "iPhone " + MEMBER_2_FIRST_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPhone", "location": None, "deviceModel": "iphone11-1-7-0", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432335, "statusCode": "200", }, "canWipeAfterLock": False, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 6, "deviceStatus": "203", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": False, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": True, "LKL": True, "LST": False, "LKM": True, "WMG": False, "PSS": False, "PIN": True, "LCK": True, "REM": True, "MCS": False, "KEY": True, "KPD": True, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": "iMac10,1", "id": "iMac10,1" + MEMBER_1_PERSON_ID, "remoteLock": None, "isLocating": False, "modelDisplayName": "iMac", "lostTimestamp": "", "batteryLevel": 0.0, "mesg": {"createTimestamp": 1583057432335, "statusCode": "200"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": True, "lostDevice": None, "lostModeCapable": False, "wipedTimestamp": None, "deviceDisplayName": "iMac", "prsId": MEMBER_1_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": 'iMac 27" ' + MEMBER_1_LAST_NAME, "isMac": True, "thisDevice": False, "deviceClass": "iMac", "location": None, "deviceModel": "iMac10_1", "maxMsgChar": 500, "darkWake": False, "remoteWipe": None, }, { "msg": None, "canWipeAfterLock": True, "baUUID": UUID + IPAD7_3 + MEMBER_1_PERSON_ID, "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": True, "passcodeLength": 6, "deviceStatus": "201", "deviceColor": "2-2-0", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": True, "LST": True, "LKM": False, "WMG": True, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": IPAD7_3, "id": IPAD7_3 + MEMBER_1_PERSON_ID, "remoteLock": None, "isLocating": False, "modelDisplayName": "iPad", "lostTimestamp": "", "batteryLevel": 0.3799999952316284, "mesg": None, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": True, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPad Pro", "prsId": MEMBER_1_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "NotCharging", "trackingInfo": None, "name": "iPad " + MEMBER_1_LAST_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPad", "location": None, "deviceModel": "NinthGen-2-2-0", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432335, "statusCode": "205", }, "canWipeAfterLock": True, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": False, "passcodeLength": 4, "deviceStatus": "203", "deviceColor": None, "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": True, "LST": True, "LKM": False, "WMG": False, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": False, "rawDeviceModel": IPHONE4_1, "id": IPHONE4_1 + MEMBER_1_PERSON_ID, "remoteLock": None, "isLocating": False, "modelDisplayName": "iPhone", "lostTimestamp": "", "batteryLevel": 0.0, "mesg": {"createTimestamp": 1583057432335, "statusCode": "205"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": True, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPhone 4s", "prsId": MEMBER_1_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "Unknown", "trackingInfo": None, "name": "iPhone", "isMac": False, "thisDevice": False, "deviceClass": "iPhone", "location": None, "deviceModel": "FifthGen", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, { "msg": { "strobe": False, "userText": True, "playSound": False, "vibrate": False, "createTimestamp": 1583057432335, "statusCode": "200", }, "canWipeAfterLock": True, "baUUID": "", "wipeInProgress": False, "lostModeEnabled": False, "activationLocked": True, "passcodeLength": 6, "deviceStatus": "201", "deviceColor": "e1e4e3-d7d9d8", "features": { "BTR": False, "LLC": False, "CLK": False, "TEU": True, "SND": True, "CLT": False, "SVP": False, "SPN": False, "XRM": False, "CWP": False, "MSG": True, "LOC": True, "LMG": False, "LKL": False, "LST": True, "LKM": False, "WMG": True, "PSS": False, "PIN": False, "LCK": True, "REM": True, "MCS": False, "KEY": False, "KPD": False, "WIP": True, }, "lowPowerMode": True, "rawDeviceModel": "iPhone6,2", "id": "iPhone6,2" + MEMBER_1_PERSON_ID, "remoteLock": None, "isLocating": True, "modelDisplayName": "iPhone", "lostTimestamp": "", "batteryLevel": 0.800000011920929, "mesg": {"createTimestamp": 1583057432335, "statusCode": "200"}, "locationEnabled": True, "lockedTimestamp": None, "locFoundEnabled": False, "snd": None, "fmlyShare": True, "lostDevice": None, "lostModeCapable": True, "wipedTimestamp": None, "deviceDisplayName": "iPhone 5s", "prsId": MEMBER_1_PERSON_ID, "audioChannels": [], "locationCapable": True, "batteryStatus": "NotCharging", "trackingInfo": None, "name": "iPhone de " + MEMBER_1_FIRST_NAME, "isMac": False, "thisDevice": False, "deviceClass": "iPhone", "location": { "isOld": False, "isInaccurate": False, "altitude": 0.0, "positionType": "GPS", "latitude": LOCATION_LATITUDE, "floorLevel": 0, "horizontalAccuracy": 50.0, "locationType": "", "timeStamp": 1585866941186, "locationFinished": False, "verticalAccuracy": 0.0, "longitude": LOCATION_LONGITUDE, }, "deviceModel": "5s-e1e4e3-d7d9d8", "maxMsgChar": 160, "darkWake": False, "remoteWipe": None, }, ], "statusCode": "200", } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/const_login.py0000644000175100001660000003544115023360704017006 0ustar00runnerdocker"""Login test constants.""" from typing import Any from tests.const_account_family import ( APPLE_ID_EMAIL, FIRST_NAME, FULL_NAME, ICLOUD_ID_EMAIL, LAST_NAME, PERSON_ID, PRIMARY_EMAIL, ) NOTIFICATION_ID: str = "12345678-1234-1234-1234-123456789012" + PERSON_ID A_DS_ID: str = "123456-12-12345678-1234-1234-1234-123456789012" + PERSON_ID WIDGET_KEY: str = "widget_key" + PERSON_ID # Data AUTH_OK: dict[str, Any] = { "authType": "hsa2", "salt": "U29tZVNhbHQ=", "b": "U29tZUJ5dGVz", "c": "TestC", "iteration": 1000, "dsInfo": {"hsaVersion": 1}, "hsaChallengeRequired": False, "webservices": "TestWebservices", } ICLOUD_UPLOAD_PHOTOS_WS_URL = "https://p31-uploadphotosws.icloud.com:443" ICLOUD_WIDGET_ACCOUNT_URL = "https://appleid.apple.com/widget/account/?widgetKey=" LOGIN_WORKING: dict[str, Any] = { "dsInfo": { "lastName": LAST_NAME, "iCDPEnabled": False, "tantorMigrated": True, "dsid": PERSON_ID, "hsaEnabled": True, "ironcadeMigrated": True, "locale": "fr-fr_FR", "brZoneConsolidated": False, "isManagedAppleID": False, "gilligan-invited": "true", "appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL], "hsaVersion": 2, "isPaidDeveloper": False, "countryCode": "FRA", "notificationId": NOTIFICATION_ID, "primaryEmailVerified": True, "aDsID": A_DS_ID, "locked": False, "hasICloudQualifyingDevice": True, "primaryEmail": PRIMARY_EMAIL, "appleIdEntries": [ {"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL}, {"type": "EMAIL", "value": APPLE_ID_EMAIL}, {"type": "EMAIL", "value": ICLOUD_ID_EMAIL}, ], "gilligan-enabled": "true", "fullName": FULL_NAME, "languageCode": "fr-fr", "appleId": PRIMARY_EMAIL, "firstName": FIRST_NAME, "iCloudAppleIdAlias": ICLOUD_ID_EMAIL, "notesMigrated": True, "hasPaymentInfo": False, "pcsDeleted": False, "appleIdAlias": APPLE_ID_EMAIL, "brMigrated": True, "statusCode": 2, "familyEligible": True, }, "hasMinimumDeviceForPhotosWeb": True, "iCDPEnabled": False, "webservices": { "reminders": { "url": "https://p31-remindersws.icloud.com:443", "status": "active", }, "notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"}, "mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"}, "ckdatabasews": { "pcsRequired": True, "url": "https://p31-ckdatabasews.icloud.com:443", "status": "active", }, "photosupload": { "pcsRequired": True, "url": ICLOUD_UPLOAD_PHOTOS_WS_URL, "status": "active", }, "photos": { "pcsRequired": True, "uploadUrl": ICLOUD_UPLOAD_PHOTOS_WS_URL, "url": "https://p31-photosws.icloud.com:443", "status": "active", }, "drivews": { "pcsRequired": True, "url": "https://p31-drivews.icloud.com:443", "status": "active", }, "uploadimagews": { "url": "https://p31-uploadimagews.icloud.com:443", "status": "active", }, "schoolwork": {}, "cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"}, "findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"}, "premiummailsettings": { "url": "https://p42-maildomainws.icloud.com:443", "status": "active", }, "ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"}, "iworkthumbnailws": { "url": "https://p31-iworkthumbnailws.icloud.com:443", "status": "active", }, "calendar": { "url": "https://p31-calendarws.icloud.com:443", "status": "active", }, "docws": { "pcsRequired": True, "url": "https://p31-docws.icloud.com:443", "status": "active", }, "settings": { "url": "https://p31-settingsws.icloud.com:443", "status": "active", }, "ubiquity": { "url": "https://p31-ubiquityws.icloud.com:443", "status": "active", }, "streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"}, "keyvalue": { "url": "https://p31-keyvalueservice.icloud.com:443", "status": "active", }, "archivews": { "url": "https://p31-archivews.icloud.com:443", "status": "active", }, "push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"}, "iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"}, "iworkexportws": { "url": "https://p31-iworkexportws.icloud.com:443", "status": "active", }, "geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"}, "account": { "iCloudEnv": {"shortId": "p", "vipSuffix": "prod"}, "url": "https://p31-setup.icloud.com:443", "status": "active", }, "fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"}, "contacts": { "url": "https://p31-contactsws.icloud.com:443", "status": "active", }, }, "pcsEnabled": True, "configBag": { "urls": { "accountCreateUI": ICLOUD_WIDGET_ACCOUNT_URL + WIDGET_KEY + "#!create", "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=" + WIDGET_KEY, "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin", "accountRepairUI": ICLOUD_WIDGET_ACCOUNT_URL + WIDGET_KEY + "#!repair", "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms", "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone", "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=" + WIDGET_KEY, "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail", "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount", "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms", "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone", }, "accountCreateEnabled": "true", }, "hsaTrustedBrowser": True, "appsOrder": [ "mail", "contacts", "calendar", "photos", "iclouddrive", "notes3", "reminders", "pages", "numbers", "keynote", "newspublisher", "fmf", "find", "settings", ], "version": 2, "isExtendedLogin": True, "pcsServiceIdentitiesIncluded": True, "hsaChallengeRequired": False, "requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"}, "pcsDeleted": False, "iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True}, "apps": { "calendar": {}, "reminders": {}, "keynote": {"isQualifiedForBeta": True}, "settings": {"canLaunchWithOneFactor": True}, "mail": {}, "numbers": {"isQualifiedForBeta": True}, "photos": {}, "pages": {"isQualifiedForBeta": True}, "notes3": {}, "find": {"canLaunchWithOneFactor": True}, "iclouddrive": {}, "newspublisher": {"isHidden": True}, "fmf": {}, "contacts": {}, }, } # Setup data LOGIN_2FA = { "dsInfo": { "lastName": LAST_NAME, "iCDPEnabled": False, "tantorMigrated": True, "dsid": PERSON_ID, "hsaEnabled": True, "ironcadeMigrated": True, "locale": "fr-fr_FR", "brZoneConsolidated": False, "isManagedAppleID": False, "gilligan-invited": "true", "appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL], "hsaVersion": 2, "isPaidDeveloper": False, "countryCode": "FRA", "notificationId": NOTIFICATION_ID, "primaryEmailVerified": True, "aDsID": A_DS_ID, "locked": False, "hasICloudQualifyingDevice": True, "primaryEmail": PRIMARY_EMAIL, "appleIdEntries": [ {"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL}, {"type": "EMAIL", "value": APPLE_ID_EMAIL}, {"type": "EMAIL", "value": ICLOUD_ID_EMAIL}, ], "gilligan-enabled": "true", "fullName": FULL_NAME, "languageCode": "fr-fr", "appleId": PRIMARY_EMAIL, "firstName": FIRST_NAME, "iCloudAppleIdAlias": ICLOUD_ID_EMAIL, "notesMigrated": True, "hasPaymentInfo": True, "pcsDeleted": False, "appleIdAlias": APPLE_ID_EMAIL, "brMigrated": True, "statusCode": 2, "familyEligible": True, }, "hasMinimumDeviceForPhotosWeb": True, "iCDPEnabled": False, "webservices": { "reminders": { "url": "https://p31-remindersws.icloud.com:443", "status": "active", }, "notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"}, "mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"}, "ckdatabasews": { "pcsRequired": True, "url": "https://p31-ckdatabasews.icloud.com:443", "status": "active", }, "photosupload": { "pcsRequired": True, "url": ICLOUD_UPLOAD_PHOTOS_WS_URL, "status": "active", }, "photos": { "pcsRequired": True, "uploadUrl": ICLOUD_UPLOAD_PHOTOS_WS_URL, "url": "https://p31-photosws.icloud.com:443", "status": "active", }, "drivews": { "pcsRequired": True, "url": "https://p31-drivews.icloud.com:443", "status": "active", }, "uploadimagews": { "url": "https://p31-uploadimagews.icloud.com:443", "status": "active", }, "schoolwork": {}, "cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"}, "findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"}, "premiummailsettings": { "url": "https://p42-maildomainws.icloud.com:443", "status": "active", }, "ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"}, "iworkthumbnailws": { "url": "https://p31-iworkthumbnailws.icloud.com:443", "status": "active", }, "calendar": { "url": "https://p31-calendarws.icloud.com:443", "status": "active", }, "docws": { "pcsRequired": True, "url": "https://p31-docws.icloud.com:443", "status": "active", }, "settings": { "url": "https://p31-settingsws.icloud.com:443", "status": "active", }, "ubiquity": { "url": "https://p31-ubiquityws.icloud.com:443", "status": "active", }, "streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"}, "keyvalue": { "url": "https://p31-keyvalueservice.icloud.com:443", "status": "active", }, "archivews": { "url": "https://p31-archivews.icloud.com:443", "status": "active", }, "push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"}, "iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"}, "iworkexportws": { "url": "https://p31-iworkexportws.icloud.com:443", "status": "active", }, "geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"}, "account": { "iCloudEnv": {"shortId": "p", "vipSuffix": "prod"}, "url": "https://p31-setup.icloud.com:443", "status": "active", }, "fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"}, "contacts": { "url": "https://p31-contactsws.icloud.com:443", "status": "active", }, }, "pcsEnabled": True, "configBag": { "urls": { "accountCreateUI": ICLOUD_WIDGET_ACCOUNT_URL + WIDGET_KEY + "#!create", "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=" + WIDGET_KEY, "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin", "accountRepairUI": ICLOUD_WIDGET_ACCOUNT_URL + WIDGET_KEY + "#!repair", "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms", "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone", "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=" + WIDGET_KEY, "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail", "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount", "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms", "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone", }, "accountCreateEnabled": "true", }, "hsaTrustedBrowser": False, "appsOrder": [ "mail", "contacts", "calendar", "photos", "iclouddrive", "notes3", "reminders", "pages", "numbers", "keynote", "newspublisher", "fmf", "find", "settings", ], "version": 2, "isExtendedLogin": True, "pcsServiceIdentitiesIncluded": False, "hsaChallengeRequired": True, "requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"}, "pcsDeleted": False, "iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True}, "apps": { "calendar": {}, "reminders": {}, "keynote": {"isQualifiedForBeta": True}, "settings": {"canLaunchWithOneFactor": True}, "mail": {}, "numbers": {"isQualifiedForBeta": True}, "photos": {}, "pages": {"isQualifiedForBeta": True}, "notes3": {}, "find": {"canLaunchWithOneFactor": True}, "iclouddrive": {}, "newspublisher": {"isHidden": True}, "fmf": {}, "contacts": {}, }, } TRUSTED_DEVICE_1: dict = { "deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1", } TRUSTED_DEVICES: dict = {"devices": [TRUSTED_DEVICE_1]} VERIFICATION_CODE_OK: dict = {"success": True} VERIFICATION_CODE_KO: dict = {"success": False} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_account.py0000644000175100001660000001261615023360704017162 0ustar00runnerdocker"""Account service tests.""" from unittest.mock import MagicMock from pyicloud.base import PyiCloudService from pyicloud.services.account import AccountStorageUsage def test_repr(pyicloud_service_working: PyiCloudService) -> None: """Tests representation.""" assert ( repr(pyicloud_service_working.account) == "" ) def test_devices(pyicloud_service_working: PyiCloudService) -> None: """Tests devices.""" assert pyicloud_service_working.account.devices assert len(pyicloud_service_working.account.devices) == 2 for device in pyicloud_service_working.account.devices: assert device.name assert device.model assert device.udid assert device["serialNumber"] assert device["osVersion"] assert device["modelLargePhotoURL2x"] assert device["modelLargePhotoURL1x"] assert device["paymentMethods"] assert device["name"] assert device["model"] assert device["udid"] assert device["modelSmallPhotoURL2x"] assert device["modelSmallPhotoURL1x"] assert device["modelDisplayName"] assert ( repr(device) == "" ) def test_family(pyicloud_service_working: PyiCloudService) -> None: """Tests family members.""" assert pyicloud_service_working.account.family assert len(pyicloud_service_working.account.family) == 3 for member in pyicloud_service_working.account.family: assert member.last_name assert member.dsid assert member.original_invitation_email assert member.full_name assert member.age_classification assert member.apple_id_for_purchases assert member.apple_id assert member.first_name assert not member.has_screen_time_enabled assert not member.has_ask_to_buy_enabled assert not member.share_my_location_enabled_family_members assert member.dsid_for_purchases assert ( repr(member) == "" ) def test_storage(pyicloud_service_working: PyiCloudService) -> None: """Tests storage.""" assert pyicloud_service_working.account.storage assert ( repr(pyicloud_service_working.account.storage) == ", 'backup': , 'docs': , 'mail': }}>" ) def test_storage_usage(pyicloud_service_working: PyiCloudService) -> None: """Tests storage usage.""" assert pyicloud_service_working.account.storage.usage usage: AccountStorageUsage = pyicloud_service_working.account.storage.usage assert usage.comp_storage_in_bytes or usage.comp_storage_in_bytes == 0 assert usage.used_storage_in_bytes assert usage.used_storage_in_percent assert usage.available_storage_in_bytes assert usage.available_storage_in_percent assert usage.total_storage_in_bytes assert usage.commerce_storage_in_bytes or usage.commerce_storage_in_bytes == 0 assert not usage.quota_over assert not usage.quota_tier_max assert not usage.quota_almost_full assert not usage.quota_paid assert ( repr(usage) == "" ) def test_storage_usages_by_media(pyicloud_service_working: PyiCloudService) -> None: """Tests storage usages by media.""" assert pyicloud_service_working.account.storage.usages_by_media for ( usage_media ) in pyicloud_service_working.account.storage.usages_by_media.values(): assert usage_media.key assert usage_media.label assert usage_media.color assert usage_media.usage_in_bytes or usage_media.usage_in_bytes == 0 assert ( repr(usage_media) == "" ) def test_summary_plan( pyicloud_service_working: PyiCloudService, mock_session: MagicMock ) -> None: """Tests the summary_plan property.""" # Mock the response for the summary plan endpoint mock_response = { "planName": "iCloud+", "storageCapacity": "200GB", "price": "$2.99/month", } mock_session.get.return_value.json.return_value = mock_response pyicloud_service_working.session = mock_session # Access the summary_plan property summary_plan = pyicloud_service_working.account.summary_plan # Assertions assert summary_plan == mock_response mock_session.get.assert_called_once_with( pyicloud_service_working.account._gateway_summary_plan_url, # pylint: disable=protected-access params=pyicloud_service_working.account.params, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_base.py0000644000175100001660000003631115023360704016436 0ustar00runnerdocker""" Test the PyiCloudService and PyiCloudSession classes.""" # pylint: disable=protected-access from unittest.mock import MagicMock, mock_open, patch import pytest from requests import Response from pyicloud.base import PyiCloudService, PyiCloudSession, b64_encode from pyicloud.exceptions import ( PyiCloud2SARequiredException, PyiCloudAPIResponseException, PyiCloudFailedLoginException, PyiCloudServiceNotActivatedException, ) def test_authenticate_with_force_refresh(pyicloud_service: PyiCloudService) -> None: """Test the authenticate method with force_refresh=True.""" with ( patch("pyicloud.base.PyiCloudSession.post") as mock_post_response, patch("pyicloud.base.PyiCloudService._validate_token") as validate_token, ): pyicloud_service.session._data = {"session_token": "valid_token"} mock_post_response.json.return_value = { "apps": {"test_service": {"canLaunchWithOneFactor": True}}, "status": "success", } pyicloud_service.data = { "apps": {"test_service": {"canLaunchWithOneFactor": True}} } validate_token = MagicMock( return_value={ "status": "success", "dsInfo": {"hsaVersion": 1}, "webservices": "TestWebservices", } ) pyicloud_service._validate_token = validate_token pyicloud_service.authenticate(force_refresh=True, service="test_service") mock_post_response.assert_called_once() validate_token.assert_called_once() def test_authenticate_with_missing_token(pyicloud_service: PyiCloudService) -> None: """Test the authenticate method with missing session_token.""" with ( patch("pyicloud.base.PyiCloudSession.post") as mock_post_response, patch.object( pyicloud_service, "_authenticate_with_token", side_effect=[PyiCloudFailedLoginException, None], ) as mock_authenticate_with_token, ): mock_post_response.return_value.json.side_effect = [ { "salt": "U29tZVNhbHQ=", "b": "U29tZUJ5dGVz", "c": "TestC", "iteration": 1000, "dsInfo": {"hsaVersion": 1}, "hsaChallengeRequired": False, "webservices": "TestWebservices", }, None, ] pyicloud_service.session.post = mock_post_response pyicloud_service.session._data = {} pyicloud_service.params = {} pyicloud_service.authenticate() assert mock_post_response.call_count == 2 assert mock_authenticate_with_token.call_count == 2 def test_validate_2fa_code(pyicloud_service: PyiCloudService) -> None: """Test the validate_2fa_code method with a valid code.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 1}, "hsaChallengeRequired": False} with patch("pyicloud.base.PyiCloudSession") as mock_session: pyicloud_service.session = mock_session mock_session.data = { "scnt": "test_scnt", "session_id": "test_session_id", "session_token": "test_session_token", } mock_post_response = MagicMock() mock_post_response.status_code = 200 mock_post_response.json.return_value = {"success": True} mock_session.post.return_value = mock_post_response assert pyicloud_service.validate_2fa_code("123456") def test_validate_2fa_code_failure(pyicloud_service: PyiCloudService) -> None: """Test the validate_2fa_code method with an invalid code.""" exception = PyiCloudAPIResponseException("Invalid code") exception.code = -21669 with patch("pyicloud.base.PyiCloudSession") as mock_session: mock_session.post.side_effect = exception pyicloud_service.session = mock_session assert not pyicloud_service.validate_2fa_code("000000") @patch("pyicloud.base.CtapHidDevice.list_devices", return_value=[MagicMock()]) @patch("pyicloud.base.Fido2Client") def test_confirm_security_key_success( mock_fido2_client_cls, mock_list_devices, pyicloud_service: PyiCloudService ) -> None: """Test that the FIDO2 WebAuthn flow works""" rp_id = "example.com" challenge = "ZmFrZV9jaGFsbGVuZ2U" # Arrange pyicloud_service._submit_webauthn_assertion_response = MagicMock() pyicloud_service.trust_session = MagicMock() # Simulated WebAuthn options returned from backend pyicloud_service._get_webauthn_options = MagicMock( return_value={ "fsaChallenge": { "challenge": challenge, # base64url(fake_challenge) "keyHandles": ["a2V5MQ", "a2V5Mg"], # base64url(fake_key_ids) "rpId": rp_id, } } ) # Simulated FIDO2 response mock_response = MagicMock() mock_response.client_data = b"client_data" mock_response.signature = b"signature" mock_response.authenticator_data = b"auth_data" mock_response.user_handle = b"user_handle" mock_response.credential_id = b"cred_id" mock_fido2_client = MagicMock() mock_fido2_client.get_assertion.return_value.get_response.return_value = ( mock_response ) mock_fido2_client_cls.return_value = mock_fido2_client # Act pyicloud_service.confirm_security_key() # Assert mock_list_devices.assert_called_once() mock_fido2_client.get_assertion.assert_called_once() # Check if data was submitted correctly pyicloud_service._submit_webauthn_assertion_response.assert_called_once_with( { "challenge": challenge, "rpId": rp_id, "clientData": b64_encode(mock_response.client_data), "signatureData": b64_encode(mock_response.signature), "authenticatorData": b64_encode(mock_response.authenticator_data), "userHandle": b64_encode(mock_response.user_handle), "credentialID": b64_encode(mock_response.credential_id), } ) pyicloud_service.trust_session.assert_called_once() def test_get_webservice_url_success(pyicloud_service: PyiCloudService) -> None: """Test the get_webservice_url method with a valid key.""" pyicloud_service._webservices = {"test_key": {"url": "https://example.com"}} url: str = pyicloud_service.get_webservice_url("test_key") assert url == "https://example.com" def test_get_webservice_url_failure(pyicloud_service: PyiCloudService) -> None: """Test the get_webservice_url method with an invalid key.""" pyicloud_service._webservices = {} with pytest.raises(PyiCloudServiceNotActivatedException): pyicloud_service.get_webservice_url("invalid_key") def test_trust_session_success(pyicloud_service: PyiCloudService) -> None: """Test the trust_session method with a successful response.""" with patch("pyicloud.base.PyiCloudSession") as mock_session: mock_session.data = { "scnt": "test_scnt", "session_id": "test_session_id", "session_token": "test_session_token", } pyicloud_service.session = mock_session assert pyicloud_service.trust_session() def test_trust_session_failure(pyicloud_service: PyiCloudService) -> None: """Test the trust_session method with a failed response.""" with patch("pyicloud.base.PyiCloudSession") as mock_session: pyicloud_service.session = mock_session mock_session.get.side_effect = PyiCloudAPIResponseException("Trust failed") assert not pyicloud_service.trust_session() def test_cookiejar_path_property(pyicloud_session: PyiCloudSession) -> None: """Test the cookiejar_path property.""" path: str = pyicloud_session.cookiejar_path assert isinstance(path, str) def test_session_path_property(pyicloud_session: PyiCloudSession) -> None: """Test the session_path property.""" path: str = pyicloud_session.session_path assert isinstance(path, str) def test_requires_2sa_property(pyicloud_service: PyiCloudService) -> None: """Test the requires_2sa property.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 2}} assert pyicloud_service.requires_2sa def test_requires_2fa_property(pyicloud_service: PyiCloudService) -> None: """Test the requires_2fa property.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 2}, "hsaChallengeRequired": False} assert pyicloud_service.requires_2fa def test_is_trusted_session_property(pyicloud_service: PyiCloudService) -> None: """Test the is_trusted_session property.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 2}} assert not pyicloud_service.is_trusted_session def test_request_success(pyicloud_service_working: PyiCloudService) -> None: """Test the request method with a successful response.""" with ( patch("requests.Session.request") as mock_request, patch("builtins.open", new_callable=mock_open), patch("http.cookiejar.LWPCookieJar.save") as mock_save, ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"success": True} mock_response.headers.get.return_value = "application/json" mock_request.return_value = mock_response pyicloud_session = PyiCloudSession( pyicloud_service_working, "", cookie_directory="" ) response: Response = pyicloud_session.request( "POST", "https://example.com", data={"key": "value"} ) assert response.json() == {"success": True} assert response.headers.get("Content-Type") == "application/json" mock_request.assert_called_once_with( method="POST", url="https://example.com", data={"key": "value"}, params=None, headers=None, cookies=None, files=None, auth=None, timeout=None, allow_redirects=True, proxies=None, hooks=None, stream=None, verify=None, cert=None, json=None, ) mock_save.assert_called_once() def test_request_failure(pyicloud_service_working: PyiCloudService) -> None: """Test the request method with a failure response.""" with ( patch("requests.Session.request") as mock_request, patch("builtins.open", new_callable=mock_open) as open_mock, patch("http.cookiejar.LWPCookieJar.save") as mock_save, ): mock_response = MagicMock() mock_response.status_code = 400 mock_response.ok = False mock_response.json.return_value = {"error": "Bad Request"} mock_response.headers.get.return_value = "application/json" mock_request.return_value = mock_response pyicloud_session = PyiCloudSession( pyicloud_service_working, "", cookie_directory="" ) with pytest.raises(PyiCloudAPIResponseException): pyicloud_session.request( "POST", "https://example.com", data={"key": "value"} ) mock_request.assert_called_once_with( method="POST", url="https://example.com", data={"key": "value"}, params=None, headers=None, cookies=None, files=None, auth=None, timeout=None, allow_redirects=True, proxies=None, hooks=None, stream=None, verify=None, cert=None, json=None, ) mock_save.assert_called_once() assert open_mock.call_count == 2 def test_request_with_custom_headers(pyicloud_service_working: PyiCloudService) -> None: """Test the request method with custom headers.""" with ( patch("requests.Session.request") as mock_request, patch("builtins.open", new_callable=mock_open), patch("http.cookiejar.LWPCookieJar.save") as mock_save, ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"data": "header test"} mock_response.headers.get.return_value = "application/json" mock_request.return_value = mock_response pyicloud_session = PyiCloudSession( pyicloud_service_working, "", cookie_directory="" ) response: Response = pyicloud_session.request( "GET", "https://example.com", headers={"Custom-Header": "Value"}, ) assert response.json() == {"data": "header test"} assert response.headers.get("Content-Type") == "application/json" mock_request.assert_called_once_with( method="GET", url="https://example.com", data=None, headers={"Custom-Header": "Value"}, params=None, cookies=None, files=None, auth=None, timeout=None, allow_redirects=True, proxies=None, hooks=None, stream=None, verify=None, cert=None, json=None, ) mock_save.assert_called_once() def test_request_error_handling_for_response_conditions() -> None: """Mock the get_webservice_url to return a valid fmip_url.""" pyicloud_service = MagicMock(spec=PyiCloudService) with ( pytest.raises(PyiCloudAPIResponseException), patch("requests.Session.request") as mock_request, patch("builtins.open", new_callable=mock_open), patch("os.path.exists", return_value=False), patch("http.cookiejar.LWPCookieJar.save"), patch.object( pyicloud_service, "get_webservice_url", return_value="https://fmip.example.com", ), ): # Mock the response with conditions that cause an error. mock_response = MagicMock() mock_response.status_code = 500 mock_response.ok = False mock_response.json.return_value = {"error": "Server Error"} mock_response.headers.get.return_value = "application/json" mock_request.return_value = mock_response pyicloud_session = PyiCloudSession(pyicloud_service, "", cookie_directory="") pyicloud_service.data = {"session_token": "valid_token"} # Use the mocked fmip_url in the request. pyicloud_session.request("GET", "https://fmip.example.com/path") def test_raise_error_2sa_required(pyicloud_session: PyiCloudSession) -> None: """Test the _raise_error method with a 2SA required exception.""" with ( pytest.raises(PyiCloud2SARequiredException), patch("pyicloud.base.PyiCloudService.requires_2sa", return_value=True), ): pyicloud_session._raise_error( 401, reason="Missing X-APPLE-WEBAUTH-TOKEN cookie" ) def test_raise_error_service_not_activated(pyicloud_session: PyiCloudSession) -> None: """Test the _raise_error method with a service not activated exception.""" with pytest.raises(PyiCloudServiceNotActivatedException): pyicloud_session._raise_error("ZONE_NOT_FOUND", reason="ServiceNotActivated") def test_raise_error_access_denied(pyicloud_session: PyiCloudSession) -> None: """Test the _raise_error method with an access denied exception.""" with pytest.raises(PyiCloudAPIResponseException): pyicloud_session._raise_error("ACCESS_DENIED", reason="ACCESS_DENIED") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_calendar.py0000644000175100001660000001446715023360704017305 0ustar00runnerdocker"""Test calendar service""" from datetime import datetime from typing import Any from unittest.mock import MagicMock, patch from requests import Response from pyicloud.services.calendar import CalendarObject, CalendarService, EventObject from pyicloud.session import PyiCloudSession def test_event_object_initialization() -> None: """Test EventObject initialization and default values.""" event = EventObject(pguid="calendar123") assert event.pguid == "calendar123" assert event.title == "New Event" assert event.duration == 60 assert event.tz == "US/Pacific" assert event.guid != "" def test_event_object_request_data() -> None: """Test EventObject request_data property.""" event = EventObject(pguid="calendar123") data: dict[str, Any] = event.request_data assert "Event" in data assert "ClientState" in data assert data["Event"]["title"] == "New Event" assert "pguid" in data["Event"] assert data["Event"]["pguid"] == "calendar123" assert "guid" in data["Event"] assert "Collection" in data["ClientState"] def test_event_object_dt_to_list() -> None: """Test EventObject dt_to_list method.""" event = EventObject(pguid="calendar123") dt = datetime(2023, 1, 1, 12, 30) result = event.dt_to_list(dt) assert result == ["20230101", 2023, 1, 1, 12, 30, 750] def test_event_object_add_invitees() -> None: """Test EventObject add_invitees method.""" event = EventObject(pguid="calendar123") event.add_invitees(["test@example.com", "user@example.com"]) assert len(event.invitees) == 2 assert f"{event.guid}:test@example.com" == event.invitees[0] assert f"{event.guid}:user@example.com" == event.invitees[1] def test_calendar_object_initialization() -> None: """Test CalendarObject initialization and default values.""" calendar = CalendarObject(title="My Calendar") assert calendar.title == "My Calendar" assert calendar.guid != "" assert calendar.color.startswith("#") def test_calendar_object_request_data() -> None: """Test CalendarObject request_data property.""" calendar = CalendarObject(title="My Calendar") data: dict[str, Any] = calendar.request_data assert "Collection" in data assert data["Collection"]["title"] == "My Calendar" assert "ClientState" in data assert "guid" in data["Collection"] assert "color" in data["Collection"] def test_calendar_service_get_calendars() -> None: """Test CalendarService get_calendars method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"Collection": [{"title": "Test Calendar"}]} mock_session.get.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) calendars = service.get_calendars() assert len(calendars) == 1 assert calendars[0]["title"] == "Test Calendar" def test_calendar_service_add_calendar() -> None: """Test CalendarService add_calendar method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"status": "success"} mock_session.post.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) calendar = CalendarObject(title="New Calendar") response = service.add_calendar(calendar) assert response["status"] == "success" def test_calendar_service_remove_calendar() -> None: """Test CalendarService remove_calendar method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"status": "success"} mock_session.post.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) response = service.remove_calendar("calendar123") assert response["status"] == "success" def test_calendar_service_get_events() -> None: """Test CalendarService get_events method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"Event": [{"title": "Test Event"}]} mock_session.get.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) events = service.get_events() assert len(events) == 1 assert events[0]["title"] == "Test Event" def test_calendar_service_add_event() -> None: """Test CalendarService add_event method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"status": "success"} mock_session.post.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) service.get_ctag = MagicMock(return_value="etag123") event = EventObject(pguid="calendar123", title="New Event") response = service.add_event(event) assert response["status"] == "success" def test_calendar_service_remove_event() -> None: """Test CalendarService remove_event method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"status": "success"} mock_session.post.return_value = mock_response with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service = CalendarService( "https://example.com", mock_session, {"dsid": "12345"} ) service.get_ctag = MagicMock(return_value="etag123") event = EventObject(pguid="calendar123", title="New Event") response = service.remove_event(event) assert response["status"] == "success" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_cmdline.py0000644000175100001660000003062515023360704017141 0ustar00runnerdocker"""Cmdline tests.""" import argparse import pickle from io import BytesIO from typing import Any from unittest.mock import MagicMock, 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 tests import PyiCloudSessionMock from tests.const import ( AUTHENTICATED_USER, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_PASSWORD, ) from tests.const_findmyiphone import FMI_FAMILY_WORKING # Dictionary to store written data written_data: dict[str, Any] = {} # Custom side effect function for open def mock_file_open(filepath: str, mode="r", **_): """Mock file open function.""" if "w" in mode or "a" in mode: # Writing or appending mode def mock_write(content): if filepath not in written_data: written_data[filepath] = "" if "a" in mode: # Append mode written_data[filepath] += content else: # Write mode written_data[filepath] = content mock_file = mock_open().return_value mock_file.write = mock_write return mock_file elif "r" in mode: raise FileNotFoundError(f"No such file or directory: '{filepath}'") else: raise ValueError(f"Unsupported mode: {mode}") def test_no_arg() -> None: """Test no args.""" with pytest.raises(SystemExit, match="2"): main() def test_username_password_invalid() -> None: """Test username and password commands.""" # No password supplied with ( patch("getpass.getpass", return_value=None), patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch("builtins.open", new_callable=mock_open), patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), pytest.raises(SystemExit, match="2"), ): mock_parse_args.return_value = argparse.Namespace( username="valid_user", password=None, debug=False, interactive=True, china_mainland=False, delete_from_keyring=False, loglevel="info", ) main() # 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.PyiCloudSession", new=PyiCloudSessionMock), pytest.raises(RuntimeError, match="Bad username or password for invalid_user"), ): mock_parse_args.return_value = argparse.Namespace( username="invalid_user", password=None, debug=False, interactive=True, china_mainland=False, delete_from_keyring=False, loglevel="error", ) main() # We should not use getpass for this one, but we reset the password at login fail with ( patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch("builtins.open", new_callable=mock_open), patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), pytest.raises(RuntimeError, match="Bad username or password for invalid_user"), ): mock_parse_args.return_value = argparse.Namespace( username="invalid_user", password="invalid_pass", debug=False, interactive=False, china_mainland=False, delete_from_keyring=False, loglevel="warning", ) main() 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.PyiCloudSession", new=PyiCloudSessionMock), ): mock_parse_args.return_value = argparse.Namespace( username=REQUIRES_2FA_USER, password=VALID_PASSWORD, debug=False, interactive=True, china_mainland=False, delete_from_keyring=False, device_id=None, locate=None, output_to_file=None, longlist=None, list=None, sound=None, message=None, silentmessage=None, lostmode=None, loglevel="warning", ) main() def test_device_outputfile() -> None: """Test the outputfile command.""" with ( patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch("builtins.open", mock_file_open), patch("keyring.get_password", return_value=None), patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), ): mock_parse_args.return_value = argparse.Namespace( username=AUTHENTICATED_USER, password=VALID_PASSWORD, debug=False, interactive=False, china_mainland=False, delete_from_keyring=False, device_id=None, locate=None, output_to_file=True, longlist=None, list=None, sound=None, message=None, silentmessage=None, lostmode=None, loglevel="none", ) 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 written_data buffer = BytesIO(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.content = {"key": "value"} filename = "test.pkl" with ( patch("builtins.open", new_callable=mock_open) as mock_file, patch("pickle.dump") as mock_pickle_dump, patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), ): create_pickled_data(idevice, filename) mock_file.assert_called_with(filename, "wb") mock_pickle_dump.assert_called_with( idevice.content, mock_file(), protocol=pickle.HIGHEST_PROTOCOL ) def test_create_parser() -> None: """Test the creation of the parser.""" parser: argparse.ArgumentParser = _create_parser() assert isinstance(parser, argparse.ArgumentParser) def test_enable_lost_mode_option() -> None: """Test the enable lost mode option.""" command_line = MagicMock( lostmode=True, device_id="123", lost_phone="1234567890", lost_message="Lost", lost_password="pass", ) dev = MagicMock() _enable_lost_mode_option(command_line, dev) dev.lost_device.assert_called_with( number="1234567890", text="Lost", newpasscode="pass" ) def test_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 ) def test_display_device_silent_message_option() -> None: """Test the display device silent message option.""" command_line = MagicMock(silentmessage="Silent Message", device_id="123") dev = MagicMock() _display_device_silent_message_option(command_line, dev) dev.display_message.assert_called_with( subject="A Silent Message", message="Silent Message", sounds=False ) def test_play_device_sound_option() -> None: """Test the play device sound option.""" command_line = MagicMock(sound=True, device_id="123") dev = MagicMock() _play_device_sound_option(command_line, dev) dev.play_sound.assert_called_once() def test_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"}], ), ): _handle_2sa(api) api.send_verification_code.assert_called_once_with( {"deviceName": "Test Device"} ) api.validate_verification_code.assert_called_once_with( {"deviceName": "Test Device"}, "123456", ) 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_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, ) # Create a mock device object dev = MagicMock() # Call the function _list_devices_option(command_line, dev) # Verify that the location() method was called dev.location.assert_called_once() def test_list_devices_option() -> None: """Test the list devices option.""" command_line = MagicMock( longlist=True, locate=False, output_to_file=False, list=False, ) dev = MagicMock( content={ "name": "Test Device", "deviceDisplayName": "Test Display", "location": "Test Location", "batteryLevel": "100%", "batteryStatus": "Charging", "deviceClass": "Phone", "deviceModel": "iPhone", } ) 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 dev.content.items(): mock_print.assert_any_call(f"{key:>20} - {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 dev = MagicMock( content={ "name": "Test Device", "deviceDisplayName": "Test Display", "location": "Test Location", "batteryLevel": "100%", "batteryStatus": "Charging", "deviceClass": "Phone", "deviceModel": "iPhone", } ) with patch("builtins.print") as mock_print: # Call the function _list_devices_option(command_line, dev) # Verify the output for short list option mock_print.assert_any_call("-" * 30) mock_print.assert_any_call("Name - Test Device") mock_print.assert_any_call("Display Name - Test Display") mock_print.assert_any_call("Location - Test Location") mock_print.assert_any_call("Battery Level - 100%") mock_print.assert_any_call("Battery Status - Charging") mock_print.assert_any_call("Device Class - Phone") mock_print.assert_any_call("Device Model - iPhone") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_contacts.py0000644000175100001660000000660615023360704017346 0ustar00runnerdocker"""Unit tests for the ContactsService and MeCard classes.""" from unittest.mock import MagicMock, patch import pytest from pyicloud.services.contacts import ContactsService, MeCard def test_contacts_service_initialization(contacts_service: ContactsService) -> None: """Test the initialization of ContactsService.""" assert contacts_service._contacts_endpoint == "https://example.com/co" # pylint: disable=protected-access assert contacts_service._contacts_refresh_url == "https://example.com/co/startup" # pylint: disable=protected-access assert contacts_service._contacts_next_url == "https://example.com/co/contacts" # pylint: disable=protected-access assert ( contacts_service._contacts_changeset_url == "https://example.com/co/changeset" # pylint: disable=protected-access ) assert contacts_service._contacts_me_card_url == "https://example.com/co/mecard" # pylint: disable=protected-access assert contacts_service._contacts is None # pylint: disable=protected-access @patch("requests.Response") def test_refresh_client( mock_response, contacts_service: ContactsService, mock_session: MagicMock ) -> None: """Test the refresh_client method.""" mock_response.json.return_value = { "prefToken": "test_pref_token", "syncToken": "test_sync_token", "contacts": [{"firstName": "John", "lastName": "Doe"}], } mock_session.get.return_value = mock_response contacts_service.refresh_client() mock_session.get.assert_called() assert contacts_service._contacts == [ # pylint: disable=protected-access { "firstName": "John", "lastName": "Doe", } ] @patch("requests.Response") def test_all_property( mock_response, contacts_service: ContactsService, mock_session: MagicMock ) -> None: """Test the all property.""" mock_response.json.return_value = { "prefToken": "test_pref_token", "syncToken": "test_sync_token", "contacts": [{"firstName": "John", "lastName": "Doe"}], } mock_session.get.return_value = mock_response contacts = contacts_service.all mock_session.get.assert_called() assert contacts == [{"firstName": "John", "lastName": "Doe"}] @patch("requests.Response") def test_me_property( mock_response, contacts_service: ContactsService, mock_session: MagicMock ) -> None: """Test the me property.""" mock_response.json.return_value = { "contacts": [{"firstName": "Jane", "lastName": "Smith", "photo": "photo_url"}] } mock_session.get.return_value = mock_response me_card: MeCard = contacts_service.me mock_session.get.assert_called() assert isinstance(me_card, MeCard) assert me_card.first_name == "Jane" assert me_card.last_name == "Smith" assert me_card.photo == "photo_url" def test_me_card_initialization() -> None: """Test the initialization of MeCard.""" data: dict[str, list[dict[str, str]]] = { "contacts": [ {"firstName": "Alice", "lastName": "Johnson", "photo": "photo_url"} ] } me_card = MeCard(data) assert me_card.first_name == "Alice" assert me_card.last_name == "Johnson" assert me_card.photo == "photo_url" assert me_card.raw_data == data def test_me_card_invalid_data() -> None: """Test MeCard initialization with invalid data.""" with pytest.raises(KeyError): MeCard({"invalid_key": "value"}) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_drive.py0000644000175100001660000005716015023360704016642 0ustar00runnerdocker"""Drive service tests.""" import json from typing import Optional from unittest.mock import ANY, Mock, patch import pytest from pyicloud.base import PyiCloudService from pyicloud.const import CONTENT_TYPE, CONTENT_TYPE_TEXT from pyicloud.exceptions import PyiCloudAPIResponseException from pyicloud.services.drive import ( CLOUD_DOCS_ZONE, NODE_TRASH, DriveNode, DriveService, ) def test_root(pyicloud_service_working: PyiCloudService) -> None: """Test the root folder.""" drive: DriveService = pyicloud_service_working.drive # root name is now extracted from drivewsid. assert drive.name == "root" assert drive.type == "folder" assert drive.size is None assert drive.date_changed is None assert drive.date_modified is None assert drive.date_last_open is None assert drive.dir() == ["Keynote", "Numbers", "Pages", "Preview", "pyiCloud"] def test_trash(pyicloud_service_working: PyiCloudService) -> None: """Test the trash folder.""" trash: DriveNode = pyicloud_service_working.drive.trash assert trash.name == NODE_TRASH assert trash.type == DriveNode.TYPE_TRASH assert trash.size is None assert trash.date_changed is None assert trash.date_modified is None assert trash.date_last_open is None assert trash.dir() == [ "dead-file.download", "test_create_folder", "test_delete_forever_and_ever", "test_files_1", "test_random_uuid", "test12345", ] def test_trash_recover(pyicloud_service_working: PyiCloudService) -> None: """Test recovering a file from the Trash.""" trash_node = pyicloud_service_working.drive.trash["test_random_uuid"] assert trash_node is not None recover_result = trash_node.recover() recover_result_items = recover_result["items"][0] assert recover_result_items["status"] == "OK" assert recover_result_items["parentId"] == "FOLDER::com.apple.CloudDocs::root" assert recover_result_items["name"] == "test_random_uuid" def test_trash_delete_forever(pyicloud_service_working: PyiCloudService) -> None: """Test permanently deleting a file from the Trash.""" node = pyicloud_service_working.drive.trash["test_delete_forever_and_ever"] assert node is not None, "Expected a valid trash node before deleting forever." recover_result = node.delete_forever() recover_result_items = recover_result["items"][0] assert recover_result_items["status"] == "OK" assert ( recover_result_items["parentId"] == "FOLDER::com.apple.CloudDocs::43D7C666-6E6E-4522-8999-0B519C3A1F4B" ) assert recover_result_items["name"] == "test_delete_forever_and_ever" def test_folder_app(pyicloud_service_working: PyiCloudService) -> None: """Test the /Preview folder.""" folder: Optional[DriveNode] = pyicloud_service_working.drive["Preview"] assert folder assert folder.name == "Preview" assert folder.type == "app_library" assert folder.size is None assert folder.date_changed is None assert folder.date_modified is None assert folder.date_last_open is None with pytest.raises(KeyError, match="No items in folder, status: ID_INVALID"): folder.dir() def test_folder_not_exists(pyicloud_service_working: PyiCloudService) -> None: """Test the /not_exists folder.""" with pytest.raises(KeyError, match="No child named 'not_exists' exists"): pyicloud_service_working.drive["not_exists"] # pylint: disable=pointless-statement def test_folder(pyicloud_service_working: PyiCloudService) -> None: """Test the /pyiCloud folder.""" folder: Optional[DriveNode] = pyicloud_service_working.drive["pyiCloud"] assert folder assert folder.name == "pyiCloud" assert folder.type == "folder" assert folder.size is None assert folder.date_changed is None assert folder.date_modified is None assert folder.date_last_open is None assert folder.dir() == ["Test"] def test_subfolder(pyicloud_service_working: PyiCloudService) -> None: """Test the /pyiCloud/Test folder.""" parent_folder: Optional[DriveNode] = pyicloud_service_working.drive["pyiCloud"] assert parent_folder is not None, "Expected to find 'pyiCloud' folder." folder: Optional[DriveNode] = parent_folder["Test"] assert folder assert folder.name == "Test" assert folder.type == "folder" assert folder.size is None assert folder.date_changed is None assert folder.date_modified is None assert folder.date_last_open is None assert folder.dir() == ["Document scanné 2.pdf", "Scanned document 1.pdf"] def test_subfolder_file(pyicloud_service_working: PyiCloudService) -> None: """Test the /pyiCloud/Test/Scanned document 1.pdf file.""" drive: Optional[DriveNode] = pyicloud_service_working.drive["pyiCloud"] assert drive folder: Optional[DriveNode] = drive["Test"] assert folder file_test: Optional[DriveNode] = folder["Scanned document 1.pdf"] assert file_test assert file_test.name == "Scanned document 1.pdf" assert file_test.type == "file" assert file_test.size == 21644358 assert str(file_test.date_changed) == "2020-05-03 00:16:17" assert str(file_test.date_modified) == "2020-05-03 00:15:17" assert str(file_test.date_last_open) == "2020-05-03 00:24:25" with pytest.raises(NotADirectoryError): file_test.dir() def test_file_open(pyicloud_service_working: PyiCloudService) -> None: """Test the /pyiCloud/Test/Scanned document 1.pdf file open.""" drive: Optional[DriveNode] = pyicloud_service_working.drive["pyiCloud"] assert drive folder: Optional[DriveNode] = drive["Test"] assert folder file_test: Optional[DriveNode] = folder["Scanned document 1.pdf"] assert file_test with file_test.open(stream=True) as response: assert response.raw def test_get_node_data(pyicloud_service_working: PyiCloudService) -> None: """Test retrieving node data.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"drivewsid": "test_id", "name": "Test Node"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: [mock_response]) ) as mock_post: node_data = drive.get_node_data("test_id") assert node_data == mock_response mock_post.assert_called_once_with( drive.service_root + "/retrieveItemDetailsInFolders", params=drive.params, data=json.dumps([{"drivewsid": "test_id", "partialData": False}]), ) def test_get_file(pyicloud_service_working: PyiCloudService) -> None: """Test retrieving a file.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"data_token": {"url": "https://example.com/file"}} with patch.object( drive.session, "get", side_effect=[ Mock(ok=True, json=lambda: mock_response), Mock(ok=True, content=b"file content"), ], ) as mock_get: file_response = drive.get_file("file_id") assert file_response.content == b"file content" mock_get.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/download/by_id", # pylint: disable=protected-access params={**drive.params, "document_id": "file_id"}, ) mock_get.assert_any_call("https://example.com/file", params=drive.params) def test_create_folders(pyicloud_service_working: PyiCloudService) -> None: """Test creating a folder.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"folders": [{"name": "New Folder"}]} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response) ) as mock_post: response = drive.create_folders("parent_id", "New Folder") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/createFolders", params=drive.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=ANY, ) def test_delete_items(pyicloud_service_working: PyiCloudService) -> None: """Test deleting an item.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"status": "OK"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response) ) as mock_post: response = drive.delete_items("node_id", "etag") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/deleteItems", params=drive.params, data=json.dumps( { "items": [ { "drivewsid": "node_id", "etag": "etag", "clientId": drive.params["clientId"], }, ] } ), ) def test_rename_items(pyicloud_service_working: PyiCloudService) -> None: """Test renaming an item.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"status": "OK"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response), ) as mock_post: response = drive.rename_items("node_id", "etag", "New Name") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/renameItems", params=drive.params, data=json.dumps( { "items": [ {"drivewsid": "node_id", "etag": "etag", "name": "New Name"}, ] } ), ) def test_move_items_to_trash(pyicloud_service_working: PyiCloudService) -> None: """Test moving an item to trash.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"status": "OK"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response) ) as mock_post: response = drive.move_items_to_trash("node_id", "etag") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/moveItemsToTrash", params=drive.params, data=json.dumps( { "items": [ {"drivewsid": "node_id", "etag": "etag", "clientId": "node_id"}, ] } ), ) def test_recover_items_from_trash(pyicloud_service_working: PyiCloudService) -> None: """Test recovering an item from trash.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"status": "OK"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response) ) as mock_post: response = drive.recover_items_from_trash("node_id", "etag") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/putBackItemsFromTrash", params=drive.params, data=json.dumps( { "items": [ {"drivewsid": "node_id", "etag": "etag"}, ] } ), ) def test_delete_forever_from_trash(pyicloud_service_working: PyiCloudService) -> None: """Test permanently deleting an item from trash.""" drive: DriveService = pyicloud_service_working.drive mock_response = {"status": "OK"} with patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response) ) as mock_post: response = drive.delete_forever_from_trash("node_id", "etag") assert response == mock_response mock_post.assert_called_once_with( drive.service_root + "/deleteItems", params=drive.params, data=json.dumps( { "items": [ {"drivewsid": "node_id", "etag": "etag"}, ] } ), ) def test_get_upload_contentws_url_success( mock_service_with_cookies: PyiCloudService, ) -> None: """Test successful retrieval of upload contentWS URL.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 100, 0]) # Mock file size as 100 bytes mock_response = [ {"document_id": "mock_document_id", "url": "https://example.com/upload"} ] with ( patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response), ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): document_id, url = drive._get_upload_contentws_url(mock_file) # pylint: disable=protected-access assert document_id == "mock_document_id" assert url == "https://example.com/upload" mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 100, } ), ) def test_get_upload_contentws_url_no_content_type( mock_service_with_cookies: PyiCloudService, ) -> None: """Test retrieval of upload contentWS URL when content type is None.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.unknown" mock_file.tell = Mock(side_effect=[0, 200, 0]) # Mock file size as 200 bytes mock_response = [ {"document_id": "mock_document_id", "url": "https://example.com/upload"} ] with ( patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response), ) as mock_post, patch("mimetypes.guess_type", return_value=(None, None)), ): document_id, url = drive._get_upload_contentws_url(mock_file) # pylint: disable=protected-access assert document_id == "mock_document_id" assert url == "https://example.com/upload" mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.unknown", "type": "FILE", "content_type": "", "size": 200, } ), ) def test_get_upload_contentws_url_error_response( mock_service_with_cookies: PyiCloudService, ) -> None: """Test retrieval of upload contentWS URL with an error response.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 300, 0]) # Mock file size as 300 bytes with ( patch.object( drive.session, "post", return_value=Mock(ok=False, reason="Bad Request") ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): with pytest.raises(PyiCloudAPIResponseException, match="Bad Request"): drive._get_upload_contentws_url(mock_file) # pylint: disable=protected-access mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 300, } ), ) def test_get_upload_contentws_url_invalid_response_format( mock_service_with_cookies: PyiCloudService, ) -> None: """Test retrieval of upload contentWS URL with an invalid response format.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 400, 0]) # Mock file size as 400 bytes mock_response = [] # Invalid response format with ( patch.object( drive.session, "post", return_value=Mock(ok=True, json=lambda: mock_response), ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): with pytest.raises(IndexError): drive._get_upload_contentws_url(mock_file) # pylint: disable=protected-access mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 400, } ), ) def test_send_file_success(mock_service_with_cookies: PyiCloudService) -> None: """Test successfully sending a file to iCloud Drive.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 100, 0]) # Mock file size as 100 bytes mock_upload_url_response = [ {"document_id": "mock_document_id", "url": "https://example.com/upload"} ] mock_upload_response = { "singleFile": { "fileChecksum": "mock_checksum", "wrappingKey": "mock_key", "referenceChecksum": "mock_reference", "size": 100, } } mock_update_response = {"status": "OK"} with ( patch.object( drive.session, "post", side_effect=[ Mock( ok=True, json=lambda: mock_upload_url_response ), # _get_upload_contentws_url Mock(ok=True, json=lambda: mock_upload_response), # Upload file Mock(ok=True, json=lambda: mock_update_response), # _update_contentws ], ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): drive.send_file("mock_folder_id", mock_file) # Assert _get_upload_contentws_url call mock_post.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 100, } ), ) # Assert file upload call mock_post.assert_any_call( "https://example.com/upload", files={"test_file.txt": mock_file}, ) # Assert _update_contentws call mock_post.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/update/documents", # pylint: disable=protected-access params=drive.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=ANY, ) def test_send_file_upload_error(mock_service_with_cookies: PyiCloudService) -> None: """Test sending a file to iCloud Drive with an upload error.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 100, 0]) # Mock file size as 100 bytes mock_upload_url_response = [ {"document_id": "mock_document_id", "url": "https://example.com/upload"} ] with ( patch.object( drive.session, "post", side_effect=[ Mock( ok=True, json=lambda: mock_upload_url_response ), # _get_upload_contentws_url Mock(ok=False, reason="Upload Failed"), # Upload file ], ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): with pytest.raises(PyiCloudAPIResponseException, match="Upload Failed"): drive.send_file("mock_folder_id", mock_file) # Assert _get_upload_contentws_url call mock_post.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 100, } ), ) # Assert file upload call mock_post.assert_any_call( "https://example.com/upload", files={"test_file.txt": mock_file}, ) def test_send_file_update_error(mock_service_with_cookies: PyiCloudService) -> None: """Test sending a file to iCloud Drive with an update error.""" drive: DriveService = mock_service_with_cookies.drive mock_file = Mock() mock_file.name = "test_file.txt" mock_file.tell = Mock(side_effect=[0, 100, 0]) # Mock file size as 100 bytes mock_upload_url_response = [ {"document_id": "mock_document_id", "url": "https://example.com/upload"} ] mock_upload_response = { "singleFile": { "fileChecksum": "mock_checksum", "wrappingKey": "mock_key", "referenceChecksum": "mock_reference", "size": 100, } } with ( patch.object( drive.session, "post", side_effect=[ Mock( ok=True, json=lambda: mock_upload_url_response ), # _get_upload_contentws_url Mock(ok=True, json=lambda: mock_upload_response), # Upload file Mock(ok=False, reason="Update Failed"), # _update_contentws ], ) as mock_post, patch("mimetypes.guess_type", return_value=("text/plain", None)), ): with pytest.raises(PyiCloudAPIResponseException, match="Update Failed"): drive.send_file("mock_folder_id", mock_file) # Assert _get_upload_contentws_url call mock_post.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", # pylint: disable=protected-access params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=json.dumps( { "filename": "test_file.txt", "type": "FILE", "content_type": "text/plain", "size": 100, } ), ) # Assert file upload call mock_post.assert_any_call( "https://example.com/upload", files={"test_file.txt": mock_file}, ) # Assert _update_contentws call mock_post.assert_any_call( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/update/documents", # pylint: disable=protected-access params=drive.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, data=ANY, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_findmyiphone.py0000644000175100001660000001676215023360704020225 0ustar00runnerdocker"""Find My iPhone service tests.""" import json from unittest.mock import patch from pyicloud.base import PyiCloudService from pyicloud.services.findmyiphone import AppleDevice, FindMyiPhoneServiceManager def test_devices(pyicloud_service_working: PyiCloudService) -> None: """Tests devices.""" assert pyicloud_service_working.devices for device in pyicloud_service_working.devices: assert device["canWipeAfterLock"] is not None assert device["baUUID"] is not None assert device["wipeInProgress"] is not None assert device["lostModeEnabled"] is not None assert device["activationLocked"] is not None assert device["passcodeLength"] is not None assert device["deviceStatus"] is not None assert device["features"] is not None assert device["lowPowerMode"] is not None assert device["rawDeviceModel"] is not None assert device["id"] is not None assert device["isLocating"] is not None assert device["modelDisplayName"] is not None assert device["lostTimestamp"] is not None assert device["batteryLevel"] is not None assert device["locationEnabled"] is not None assert device["locFoundEnabled"] is not None assert device["fmlyShare"] is not None assert device["lostModeCapable"] is not None assert device["wipedTimestamp"] is None assert device["deviceDisplayName"] is not None assert device["audioChannels"] is not None assert device["locationCapable"] is not None assert device["batteryStatus"] is not None assert device["trackingInfo"] is None assert device["name"] is not None assert device["isMac"] is not None assert device["thisDevice"] is not None assert device["deviceClass"] is not None assert device["deviceModel"] is not None assert device["maxMsgChar"] is not None assert device["darkWake"] is not None assert device["remoteWipe"] is None assert device.data["canWipeAfterLock"] is not None assert device.data["baUUID"] is not None assert device.data["wipeInProgress"] is not None assert device.data["lostModeEnabled"] is not None assert device.data["activationLocked"] is not None assert device.data["passcodeLength"] is not None assert device.data["deviceStatus"] is not None assert device.data["features"] is not None assert device.data["lowPowerMode"] is not None assert device.data["rawDeviceModel"] is not None assert device.data["id"] is not None assert device.data["isLocating"] is not None assert device.data["modelDisplayName"] is not None assert device.data["lostTimestamp"] is not None assert device.data["batteryLevel"] is not None assert device.data["locationEnabled"] is not None assert device.data["locFoundEnabled"] is not None assert device.data["fmlyShare"] is not None assert device.data["lostModeCapable"] is not None assert device.data["wipedTimestamp"] is None assert device.data["deviceDisplayName"] is not None assert device.data["audioChannels"] is not None assert device.data["locationCapable"] is not None assert device.data["batteryStatus"] is not None assert device.data["trackingInfo"] is None assert device.data["name"] is not None assert device.data["isMac"] is not None assert device.data["thisDevice"] is not None assert device.data["deviceClass"] is not None assert device.data["deviceModel"] is not None assert device.data["maxMsgChar"] is not None assert device.data["darkWake"] is not None assert device.data["remoteWipe"] is None def test_apple_device_properties(pyicloud_service_working: PyiCloudService) -> None: """Tests AppleDevice properties and methods.""" device: AppleDevice = pyicloud_service_working.devices[0] # Test session property assert device.session is not None # Test location property location = device.location assert location is not None assert "latitude" in location assert "longitude" in location # Test status method status = device.status() assert "batteryLevel" in status assert "deviceDisplayName" in status assert "deviceStatus" in status assert "name" in status # Test status with additional fields additional_status = device.status(additional=["isMac", "deviceClass"]) assert "isMac" in additional_status assert "deviceClass" in additional_status # Test data property assert device.data is not None assert "id" in device.data # Test __getitem__ method assert device["id"] == device.data["id"] # Test __getattr__ method assert device.deviceDisplayName == device.data["deviceDisplayName"] # Test __str__ method assert str(device) == f"{device['deviceDisplayName']}: {device['name']}" # Test __repr__ method assert repr(device) == f"" def test_apple_device_actions(pyicloud_service_working: PyiCloudService) -> None: """Tests AppleDevice actions like play_sound, display_message, and lost_device.""" device: AppleDevice = pyicloud_service_working.devices[0] # Mock session.post to avoid actual API calls with patch.object(device.session, "post") as mock_post: # Test play_sound device.play_sound(subject="Test Alert") mock_post.assert_called_with( device.sound_url, params=device.params, data=json.dumps( { "device": device.data["id"], "subject": "Test Alert", "clientContext": {"fmly": True}, } ), ) # Test display_message device.display_message(subject="Test Message", message="Hello", sounds=True) mock_post.assert_called_with( device.message_url, params=device.params, data=json.dumps( { "device": device.data["id"], "subject": "Test Message", "sound": True, "userText": True, "text": "Hello", } ), ) # Test lost_device device.lost_device( number="1234567890", text="Lost device message", newpasscode="1234" ) mock_post.assert_called_with( device.lost_url, params=device.params, data=json.dumps( { "text": "Lost device message", "userText": True, "ownerNbr": "1234567890", "lostModeEnabled": True, "trackingEnabled": True, "device": device.data["id"], "passcode": "1234", } ), ) def test_findmyiphone_service_manager( pyicloud_service_working: PyiCloudService, ) -> None: """Tests FindMyiPhoneServiceManager methods.""" manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices # Test refresh_client manager.refresh_client() assert len(manager) > 0 # Test __getitem__ device = manager[0] assert isinstance(device, AppleDevice) # Test __len__ assert len(manager) == len(manager) # Test __iter__ devices = list(iter(manager)) assert len(devices) == len(manager) # Test __str__ and __repr__ assert str(manager) == repr(manager) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_hidemyemail.py0000644000175100001660000001302215023360704020005 0ustar00runnerdocker"""Tests for the Hide My Email service.""" import json from typing import Any, Optional from unittest.mock import MagicMock from requests import Response from pyicloud.services.hidemyemail import HideMyEmailService def test_generate( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the generate method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"hme": "alias@example.com"}} mock_session.post.return_value = mock_response result: Optional[str] = hidemyemail_service.generate() assert result == "alias@example.com" mock_session.post.assert_called_once_with( "https://example.com/v1/hme/generate", params={"dsid": "12345"} ) def test_reserve( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the reserve method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"status": "success"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service.reserve( "alias@example.com", "Test Label", "Test Note" ) assert result == {"status": "success"} mock_session.post.assert_called_once_with( "https://example.com/v1/hme/reserve", params={"dsid": "12345"}, data=json.dumps( {"hme": "alias@example.com", "label": "Test Label", "note": "Test Note"} ), ) def test_len(hidemyemail_service: HideMyEmailService, mock_session: MagicMock) -> None: """Test the __len__ method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"hmeEmails": ["email1", "email2"]}} mock_session.get.return_value = mock_response result: int = len(hidemyemail_service) assert result == 2 mock_session.get.assert_called_once_with( "https://example.com/v2/hme/list", params={"dsid": "12345"} ) def test_iter(hidemyemail_service: HideMyEmailService, mock_session: MagicMock) -> None: """Test the __iter__ method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"hmeEmails": ["email1", "email2"]}} mock_session.get.return_value = mock_response emails = list(iter(hidemyemail_service)) assert emails == ["email1", "email2"] mock_session.get.assert_called_once_with( "https://example.com/v2/hme/list", params={"dsid": "12345"} ) def test_getitem( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the __getitem__ method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"email": "alias@example.com"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service["12345"] assert result == {"email": "alias@example.com"} mock_session.post.assert_called_once_with( "https://example.com/v2/hme/get", params={"dsid": "12345"}, data=json.dumps({"anonymousId": "12345"}), ) def test_update_metadata( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the update_metadata method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"status": "updated"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service.update_metadata( "12345", "New Label", "New Note" ) assert result == {"status": "updated"} mock_session.post.assert_called_once_with( "https://example.com/v1/hme/updateMetaData", params={"dsid": "12345"}, data=json.dumps( {"anonymousId": "12345", "label": "New Label", "note": "New Note"} ), ) def test_delete( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the delete method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"status": "deleted"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service.delete("12345") assert result == {"status": "deleted"} mock_session.post.assert_called_once_with( "https://example.com/v1/hme/delete", params={"dsid": "12345"}, data=json.dumps({"anonymousId": "12345"}), ) def test_deactivate( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the deactivate method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"status": "deactivated"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service.deactivate("12345") assert result == {"status": "deactivated"} mock_session.post.assert_called_once_with( "https://example.com/v1/hme/deactivate", params={"dsid": "12345"}, data=json.dumps({"anonymousId": "12345"}), ) def test_reactivate( hidemyemail_service: HideMyEmailService, mock_session: MagicMock ) -> None: """Test the reactivate method.""" mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"result": {"status": "reactivated"}} mock_session.post.return_value = mock_response result: dict[str, Any] = hidemyemail_service.reactivate("12345") assert result == {"status": "reactivated"} mock_session.post.assert_called_once_with( "https://example.com/v1/hme/reactivate", params={"dsid": "12345"}, data=json.dumps({"anonymousId": "12345"}), ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_photos.py0000644000175100001660000006353415023360704017047 0ustar00runnerdocker"""PhotoLibrary tests.""" import json from typing import Any from unittest.mock import MagicMock, mock_open, patch import pytest from pyicloud.const import CONTENT_TYPE, CONTENT_TYPE_TEXT from pyicloud.exceptions import ( PyiCloudAPIResponseException, PyiCloudServiceNotActivatedException, ) from pyicloud.services.photos import ( AlbumContainer, BasePhotoAlbum, BasePhotoLibrary, DirectionEnum, PhotoAlbum, PhotoLibrary, PhotosService, PhotoStreamLibrary, SharedPhotoStreamAlbum, SmartAlbumEnum, ) def test_photo_library_initialization(mock_photos_service: MagicMock) -> None: """Tests initialization of PhotoLibrary.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) assert library.zone_id == {"zoneName": "PrimarySync"} assert library.url == ("https://example.com/records/query?dsid=12345") def test_photo_library_indexing_not_finished(mock_photos_service: MagicMock) -> None: """Tests exception when indexing is not finished.""" mock_photos_service.session.post.return_value.json.return_value = { "records": [ { "fields": { "state": {"value": "NOT_FINISHED"}, }, } ] } with pytest.raises(PyiCloudServiceNotActivatedException): PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) def test_fetch_folders(mock_photos_service: MagicMock) -> None: """Tests the _fetch_folders method.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) library.SMART_ALBUMS = {} albums: list[BasePhotoAlbum] = list(library.albums.values()) assert len(albums) == 1 assert albums[0].name == "folder1" def test_get_albums(mock_photos_service: MagicMock) -> None: """Tests the _get_albums method.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) albums: AlbumContainer = library.albums assert SmartAlbumEnum.ALL_PHOTOS in albums assert "folder1" in albums assert albums["folder1"].name == "folder1" assert albums["folder1"].direction == DirectionEnum.ASCENDING def test_upload_file_success(mock_photos_service: MagicMock) -> None: """Tests the upload_file method for successful upload.""" mock_photos_service.params = {"dsid": "12345"} mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "uploaded_photo", "recordType": "CPLAsset", } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) with patch("builtins.open", mock_open(read_data=b"file_content")): record_name: dict[str, Any] = library.upload_file("test_photo.jpg") assert record_name == "uploaded_photo" mock_photos_service.session.post.assert_called_with( url="https://upload.example.com/upload", data=b"file_content", params={"filename": "test_photo.jpg", "dsid": "12345"}, ) def test_upload_file_with_errors(mock_photos_service: MagicMock) -> None: """Tests the upload_file method when the response contains errors.""" mock_photos_service.params = {"dsid": "12345"} mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "errors": [ { "code": "UPLOAD_ERROR", "message": "Upload failed", }, ], } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) with patch("builtins.open", mock_open(read_data=b"file_content")): with pytest.raises(PyiCloudAPIResponseException) as exc_info: library.upload_file("test_photo.jpg") assert "UPLOAD_ERROR" in str(exc_info.value) mock_photos_service.session.post.assert_called_with( url="https://upload.example.com/upload", data=b"file_content", params={"filename": "test_photo.jpg", "dsid": "12345"}, ) def test_upload_file_no_records(mock_photos_service: MagicMock) -> None: """Tests the upload_file method when no records are returned.""" mock_photos_service.params = {"dsid": "12345"} mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [], } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) with patch("builtins.open", mock_open(read_data=b"file_content")): with pytest.raises(IndexError): library.upload_file("test_photo.jpg") mock_photos_service.session.post.assert_called_with( url="https://upload.example.com/upload", data=b"file_content", params={"filename": "test_photo.jpg", "dsid": "12345"}, ) def test_fetch_folders_multiple_pages(mock_photos_service: MagicMock) -> None: """Tests _fetch_folders with multiple pages of results.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": False}, }, } ], "continuationMarker": "marker1", } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder2", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMg=="}, "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) library.SMART_ALBUMS = {} albums: list[BasePhotoAlbum] = list(library.albums.values()) assert len(albums) == 2 assert albums[0].name == "folder1" assert albums[1].name == "folder2" mock_photos_service.session.post.assert_called() def test_fetch_folders_skips_deleted_folders(mock_photos_service: MagicMock) -> None: """Tests _fetch_folders skips folders marked as deleted.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "continuationMarker": "marker1", "records": [ { "recordName": "folder1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": True}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder2", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMg=="}, "isDeleted": {"value": False}, }, }, ] }, ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) library.SMART_ALBUMS = {} albums: list[BasePhotoAlbum] = list(library.albums.values()) assert len(albums) == 1 assert albums[0].name == "folder2" mock_photos_service.session.post.assert_called() def test_fetch_folders_no_records(mock_photos_service: MagicMock) -> None: """Tests _fetch_folders when no records are returned.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [], } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) library.SMART_ALBUMS = {} albums: AlbumContainer = library.albums assert len(albums) == 0 mock_photos_service.session.post.assert_called() def test_fetch_folders_handles_missing_fields(mock_photos_service: MagicMock) -> None: """Tests _fetch_folders handles records with missing fields.""" mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder1", "fields": { "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) library.SMART_ALBUMS = {} albums: AlbumContainer = library.albums assert len(albums) == 0 mock_photos_service.session.post.assert_called() def test_base_photo_album_initialization(mock_photo_library: MagicMock) -> None: """Tests initialization of BasePhotoAlbum.""" album = BasePhotoAlbum( library=mock_photo_library, name="Test Album", list_type="CPLAssetAndMasterByAssetDate", asset_type=MagicMock, page_size=50, direction=DirectionEnum.ASCENDING, ) assert album.name == "Test Album" assert album.service == mock_photo_library.service assert album.page_size == 50 assert album.direction == DirectionEnum.ASCENDING assert album.list_type == "CPLAssetAndMasterByAssetDate" assert album.asset_type == MagicMock def test_base_photo_album_parse_response() -> None: """Tests the _parse_response method.""" album = BasePhotoAlbum( library=MagicMock(), name="Test Album", list_type="CPLAssetAndMasterByAssetDate", asset_type=MagicMock, ) response = { "records": [ { "recordType": "CPLAsset", "fields": {"masterRef": {"value": {"recordName": "master1"}}}, }, { "recordType": "CPLMaster", "recordName": "master1", }, ] } asset_records, master_records = album._parse_response(response) # pylint: disable=protected-access assert "master1" in asset_records assert len(master_records) == 1 assert master_records[0]["recordName"] == "master1" def test_base_photo_album_get_photos_at(mock_photo_library: MagicMock) -> None: """Tests the _get_photos_at method.""" mock_photo_library.service.session.post.return_value.json.side_effect = [ { "records": [ { "recordType": "CPLAsset", "fields": {"masterRef": {"value": {"recordName": "master1"}}}, }, { "recordType": "CPLMaster", "recordName": "master1", }, ] }, { "records": [], }, ] album = PhotoAlbum( library=mock_photo_library, name="Test Album", list_type="CPLAssetAndMasterByAssetDate", obj_type="MagicMock", direction=DirectionEnum.ASCENDING, page_size=10, url="https://example.com/records/query?dsid=12345", ) photos = list(album.photos) assert len(photos) == 1 mock_photo_library.service.session.post.assert_called() def test_base_photo_album_len(mock_photos_service: MagicMock) -> None: """Tests the __len__ method.""" album = BasePhotoAlbum( library=mock_photos_service, name="Test Album", list_type="CPLAssetAndMasterByAssetDate", asset_type=MagicMock, ) album._get_len = MagicMock(return_value=42) # pylint: disable=protected-access assert len(album) == 42 album._get_len.assert_called_once() # pylint: disable=protected-access def test_base_photo_album_iter(mock_photo_library: MagicMock) -> None: """Tests the __iter__ method.""" mock_photo_library.service.session.post.return_value.json.side_effect = [ { "records": [ { "recordType": "CPLAsset", "fields": {"masterRef": {"value": {"recordName": "master1"}}}, }, { "recordType": "CPLMaster", "recordName": "master1", }, ] }, { "records": [], }, ] album = PhotoAlbum( library=mock_photo_library, name="Test Album", list_type="CPLAssetAndMasterByAssetDate", obj_type="MagicMock", direction=DirectionEnum.ASCENDING, page_size=10, url="https://example.com/records/query?dsid=12345", ) photos = list(iter(album)) assert len(photos) == 1 mock_photo_library.service.session.post.assert_called() def test_base_photo_album_str() -> None: """Tests the __str__ method.""" album = BasePhotoAlbum( library=MagicMock(), name="Test Album", list_type="CPLAssetAndMasterByAssetDate", asset_type=MagicMock, ) assert str(album) == "Test Album" def test_base_photo_album_repr() -> None: """Tests the __repr__ method.""" album = BasePhotoAlbum( library=MagicMock(), name="Test Album", list_type="CPLAssetAndMasterByAssetDate", asset_type=MagicMock, ) assert repr(album) == "" def test_photos_service_initialization(mock_photos_service: MagicMock) -> None: """Tests initialization of PhotosService.""" mock_photos_service.session.post.return_value.json.return_value = { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] } photos_service = PhotosService( service_root="https://example.com", session=mock_photos_service.session, params={"dsid": "12345"}, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) assert photos_service.service_endpoint == ( "https://example.com/database/1/com.apple.photos.cloud/production/private" ) assert isinstance(photos_service._root_library, PhotoLibrary) # pylint: disable=protected-access assert isinstance(photos_service._shared_library, PhotoStreamLibrary) # pylint: disable=protected-access assert photos_service.params["remapEnums"] is True assert photos_service.params["getCurrentSyncToken"] is True def test_photos_service_libraries(mock_photos_service: MagicMock) -> None: """Tests the libraries property.""" mock_photos_service.session.post.return_value.json.side_effect = [ { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] }, { "zones": [ {"zoneID": {"zoneName": "CustomZone"}, "deleted": False}, ] }, { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] }, ] photos_service = PhotosService( service_root="https://example.com", session=mock_photos_service.session, params={"dsid": "12345"}, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) libraries = photos_service.libraries assert "root" in libraries assert "shared" in libraries assert "CustomZone" in libraries assert isinstance(libraries["root"], PhotoLibrary) assert isinstance(libraries["shared"], PhotoStreamLibrary) assert isinstance(libraries["CustomZone"], PhotoLibrary) mock_photos_service.session.post.assert_called_with( url="https://example.com/database/1/com.apple.photos.cloud/production/private/records/query?dsid=12345&remapEnums=True&getCurrentSyncToken=True", data=json.dumps( { "query": {"recordType": "CheckIndexingState"}, "zoneID": {"zoneName": "CustomZone"}, } ), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) def test_photos_service_libraries_cached(mock_photos_service: MagicMock) -> None: """Tests that libraries are cached after the first access.""" mock_photos_service.session.post.return_value.json.return_value = { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] } photos_service = PhotosService( service_root="https://example.com", session=mock_photos_service.session, params={"dsid": "12345"}, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) mock_libraries = {"cached": MagicMock(spec=PhotoLibrary)} photos_service._libraries = mock_libraries # type: ignore # pylint: disable=protected-access libraries: dict[str, BasePhotoLibrary] = photos_service.libraries assert libraries == mock_libraries mock_photos_service.session.post.assert_called_once() def test_photos_service_albums(mock_photos_service: MagicMock) -> None: """Tests the albums property.""" mock_photos_service.session.post.return_value.json.return_value = { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] } photos_service = PhotosService( service_root="https://example.com", session=mock_photos_service.session, params={"dsid": "12345"}, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) albums: AlbumContainer = photos_service.albums assert isinstance(albums, AlbumContainer) assert SmartAlbumEnum.ALL_PHOTOS in albums mock_photos_service.session.post.assert_called() def test_photos_service_shared_streams(mock_photos_service: MagicMock) -> None: """Tests the shared_streams property.""" mock_photos_service.session.post.return_value.json.side_effect = [ { "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ] }, { "albums": [ { "albumlocation": "https://shared.example.com/album/", "albumctag": "ctag", "albumguid": "guid", "ownerdsid": "owner", "attributes": { "name": "Shared Album", "creationDate": "1234567890", "allowcontributions": True, "ispublic": False, }, "sharingtype": "owned", "iswebuploadsupported": True, } ] }, ] photos_service = PhotosService( service_root="https://example.com", session=mock_photos_service.session, params={"dsid": "12345"}, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) shared_streams: AlbumContainer = photos_service.shared_streams assert isinstance(shared_streams, AlbumContainer) assert "Shared Album" in shared_streams assert isinstance(shared_streams["Shared Album"], SharedPhotoStreamAlbum) mock_photos_service.session.post.assert_called() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_reminders.py0000644000175100001660000001233115023360704017510 0ustar00runnerdocker"""Unit tests for the RemindersService class.""" import datetime import json from unittest.mock import MagicMock, patch from requests import Response from pyicloud.services.reminders import RemindersService from pyicloud.session import PyiCloudSession def test_reminders_service_init(mock_session: MagicMock) -> None: """Test RemindersService initialization.""" mock_session.get.return_value = MagicMock( spec=Response, json=lambda: {"Collections": [], "Reminders": []} ) params: dict[str, str] = {"dsid": "12345"} with patch("pyicloud.services.reminders.get_localzone_name", return_value="UTC"): service = RemindersService("https://example.com", mock_session, params) assert service.service_root == "https://example.com" assert service.params == params assert not service.lists assert not service.collections def test_reminders_service_refresh() -> None: """Test the refresh method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = { "Collections": [ {"title": "Work", "guid": "guid1", "ctag": "ctag1"}, {"title": "Personal", "guid": "guid2", "ctag": "ctag2"}, ], "Reminders": [ {"title": "Task 1", "pGuid": "guid1", "dueDate": [2023, 10, 1, 12, 0, 0]}, {"title": "Task 2", "pGuid": "guid2", "dueDate": None}, ], } mock_session.get.return_value = mock_response with patch("pyicloud.services.reminders.get_localzone_name", return_value="UTC"): service = RemindersService( "https://example.com", mock_session, {"dsid": "12345"} ) service.refresh() assert "Work" in service.lists assert "Personal" in service.lists assert len(service.lists["Work"]) == 1 assert len(service.lists["Personal"]) == 1 work_task = service.lists["Work"][0] assert work_task["title"] == "Task 1" assert work_task["due"] == datetime.datetime(2023, 10, 1, 12, 0, 0) personal_task = service.lists["Personal"][0] assert personal_task["title"] == "Task 2" assert personal_task["due"] is None def test_reminders_service_post() -> None: """Test the post method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.ok = True mock_session.post.return_value = mock_response with patch("pyicloud.services.reminders.get_localzone_name", return_value="UTC"): service = RemindersService( "https://example.com", mock_session, {"dsid": "12345"} ) service.collections = {"Work": {"guid": "guid1"}} # Test posting a reminder with a due date due_date = datetime.datetime(2023, 10, 1, 12, 0, 0) result: bool = service.post("New Task", "Description", "Work", due_date) assert result is True mock_session.post.assert_called_once() _, kwargs = mock_session.post.call_args assert kwargs["data"] data = json.loads(kwargs["data"]) assert data["Reminders"]["title"] == "New Task" assert data["Reminders"]["description"] == "Description" assert data["Reminders"]["pGuid"] == "guid1" assert data["Reminders"]["dueDate"] == [20231001, 2023, 10, 1, 12, 0] # Test posting a reminder without a due date mock_session.post.reset_mock() result = service.post("Task Without Due Date", collection="Work") assert result is True mock_session.post.assert_called_once() _, kwargs = mock_session.post.call_args data = json.loads(kwargs["data"]) assert data["Reminders"]["title"] == "Task Without Due Date" assert data["Reminders"]["dueDate"] is None def test_reminders_service_post_invalid_collection() -> None: """Test the post method with an invalid collection.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.ok = True mock_session.post.return_value = mock_response with patch("pyicloud.services.reminders.get_localzone_name", return_value="UTC"): service = RemindersService( "https://example.com", mock_session, {"dsid": "12345"} ) # Post to a non-existent collection result = service.post("Task", collection="NonExistent") assert result is True mock_session.post.assert_called_once() _, kwargs = mock_session.post.call_args data = json.loads(kwargs["data"]) assert data["Reminders"]["pGuid"] == "tasks" # Default collection def test_reminders_service_refresh_empty_response() -> None: """Test the refresh method with an empty response.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"Collections": [], "Reminders": []} mock_session.get.return_value = mock_response with patch("pyicloud.services.reminders.get_localzone_name", return_value="UTC"): service = RemindersService( "https://example.com", mock_session, {"dsid": "12345"} ) service.refresh() assert not service.lists assert not service.collections ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749934532.0 pyicloud-2.0.1/tests/test_ubiquity.py0000644000175100001660000001202015023360704017366 0ustar00runnerdocker"""Unit tests for UbiquityService and UbiquityNode classes.""" from datetime import datetime from unittest.mock import MagicMock import pytest from requests import Response from pyicloud.exceptions import PyiCloudAPIResponseException, PyiCloudServiceUnavailable from pyicloud.services.ubiquity import UbiquityNode, UbiquityService from pyicloud.session import PyiCloudSession def test_ubiquity_service_init() -> None: """Test UbiquityService initialization and exception handling.""" mock_session = MagicMock(spec=PyiCloudSession) mock_session.get.return_value = MagicMock( spec=Response, json=lambda: {"item_list": []} ) params: dict[str, str] = {"dsid": "12345"} # Test successful initialization service = UbiquityService("https://example.com", mock_session, params) assert service.service_root == "https://example.com" assert service.params == params # Test exception handling mock_session.get.side_effect = PyiCloudAPIResponseException( code=503, reason="Service Unavailable" ) with pytest.raises(PyiCloudServiceUnavailable): UbiquityService("https://example.com", mock_session, params) def test_ubiquity_service_root() -> None: """Test the root property of UbiquityService.""" mock_session = MagicMock(spec=PyiCloudSession) mock_session.get.return_value = MagicMock( spec=Response, json=lambda: {"item_id": "0"} ) service = UbiquityService("https://example.com", mock_session, {"dsid": "12345"}) root: UbiquityNode = service.root assert isinstance(root, UbiquityNode) assert root.item_id == "0" def test_get_node_url() -> None: """Test get_node_url method.""" service = UbiquityService("https://example.com", MagicMock(), {"dsid": "12345"}) url: str = service.get_node_url("node123") assert url == "https://example.com/ws/12345/item/node123" def test_get_node() -> None: """Test get_node method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"item_id": "123"} mock_session.get.return_value = mock_response service = UbiquityService("https://example.com", mock_session, {"dsid": "12345"}) node: UbiquityNode = service.get_node("123") assert isinstance(node, UbiquityNode) assert node.item_id == "123" def test_get_children() -> None: """Test get_children method.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = { "item_list": [{"item_id": "1"}, {"item_id": "2"}] } mock_session.get.return_value = mock_response service = UbiquityService("https://example.com", mock_session, {"dsid": "12345"}) children: list[UbiquityNode] = service.get_children("123") assert len(children) == 2 assert all(isinstance(child, UbiquityNode) for child in children) def test_ubiquity_node_properties() -> None: """Test UbiquityNode properties.""" data: dict[str, str] = { "item_id": "123", "name": "Test Node", "type": "folder", "size": "1024", "modified": "2023-01-01T12:00:00Z", } node = UbiquityNode(MagicMock(), data) assert node.item_id == "123" assert node.name == "Test Node" assert node.type == "folder" assert node.size == 1024 assert node.modified == datetime(2023, 1, 1, 12, 0, 0) def test_ubiquity_node_get_children() -> None: """Test UbiquityNode get_children method.""" mock_service = MagicMock(spec=UbiquityService) mock_service.get_children.return_value = [MagicMock(spec=UbiquityNode)] node = UbiquityNode(mock_service, {"item_id": "123"}) children: list[UbiquityNode] = node.get_children() assert len(children) == 1 assert isinstance(children[0], UbiquityNode) def test_ubiquity_node_dir() -> None: """Test UbiquityNode dir method.""" mock_child = MagicMock(spec=UbiquityNode) mock_child.name = "Child Node" mock_service = MagicMock(spec=UbiquityService) mock_service.get_children.return_value = [mock_child] node = UbiquityNode(mock_service, {"item_id": "123"}) directories: list[str] = node.dir() assert directories == ["Child Node"] def test_ubiquity_node_get() -> None: """Test UbiquityNode get method.""" mock_child = MagicMock(spec=UbiquityNode, name="Child Node") mock_child.name = "Child Node" mock_service = MagicMock(spec=UbiquityService) mock_service.get_children.return_value = [mock_child] node = UbiquityNode(mock_service, {"item_id": "123"}) child: UbiquityNode = node.get("Child Node") assert child == mock_child def test_ubiquity_node_getitem() -> None: """Test UbiquityNode __getitem__ method.""" mock_child = MagicMock(spec=UbiquityNode, name="Child Node") mock_child.name = "Child Node" mock_service = MagicMock(spec=UbiquityService) mock_service.get_children.return_value = [mock_child] node = UbiquityNode(mock_service, {"item_id": "123"}) child: UbiquityNode = node["Child Node"] assert child == mock_child