././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9365506 pyicloud-2.3.0/0000755000175100017510000000000015133166716013023 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9245505 pyicloud-2.3.0/.devcontainer/0000755000175100017510000000000015133166716015562 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.devcontainer/Dockerfile0000644000175100017510000000034215133166711017546 0ustar00runnerrunnerFROM 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=1768746441.0 pyicloud-2.3.0/.devcontainer/devcontainer.json0000644000175100017510000000442215133166711021133 0ustar00runnerrunner// 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" }, "containerEnv": { "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}", "DEVCONTAINER": "1" }, // 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": {}, "ghcr.io/devcontainers/features/java:1": { "jdkDistro": "open", "gradleVersion": "latest", "mavenVersion": "latest", "antVersion": "latest", "groovyVersion": "latest" }, "ghcr.io/devcontainers/features/node:1": { "version": "lts", "pnpmVersion": "latest", "nvmVersion": "latest" }, "ghcr.io/devcontainers-extra/features/uv": {}, "ghcr.io/devcontainers-extra/features/pre-commit:2": {} }, // 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", "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", "coderabbit.coderabbit-vscode", "foxundermoon.shell-format" ], "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=1768746445.9245505 pyicloud-2.3.0/.github/0000755000175100017510000000000015133166716014363 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.github/FUNDING.yml0000644000175100017510000000152615133166711016177 0ustar00runnerrunner# 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=1768746445.9255505 pyicloud-2.3.0/.github/ISSUE_TEMPLATE/0000755000175100017510000000000015133166716016546 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.github/ISSUE_TEMPLATE/BUG.md0000644000175100017510000000211315133166711017475 0ustar00runnerrunner--- 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=1768746441.0 pyicloud-2.3.0/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md0000644000175100017510000000163715133166711021375 0ustar00runnerrunner--- 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=1768746441.0 pyicloud-2.3.0/.github/ISSUE_TEMPLATE/SUPPORT.md0000644000175100017510000000254515133166711020245 0ustar00runnerrunner--- 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=1768746441.0 pyicloud-2.3.0/.github/PULL_REQUEST_TEMPLATE.md0000644000175100017510000000506215133166711020162 0ustar00runnerrunner ## 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=1768746441.0 pyicloud-2.3.0/.github/dependabot.yml0000644000175100017510000000121415133166711017204 0ustar00runnerrunner# 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=1768746441.0 pyicloud-2.3.0/.github/release-drafter.yml0000644000175100017510000000005415133166711020145 0ustar00runnerrunnertemplate: | ## What's Changed $CHANGES ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9265504 pyicloud-2.3.0/.github/workflows/0000755000175100017510000000000015133166716016420 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.github/workflows/linting.yml0000644000175100017510000000111115133166711020574 0ustar00runnerrunnername: Linting on: [push, pull_request] permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 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=1768746441.0 pyicloud-2.3.0/.github/workflows/publish.yml0000644000175100017510000000407715133166711020614 0ustar00runnerrunnername: Build and Publish Python Package # This workflow builds a Python package and publishes it to PyPI or TestPyPI permissions: contents: read on: release: types: [published, prereleased] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 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@v6 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: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v6 - name: Download dist artifacts uses: actions/download-artifact@v7 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: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v6 - name: Download dist artifacts uses: actions/download-artifact@v7 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=1768746441.0 pyicloud-2.3.0/.github/workflows/release-drafter.yml0000644000175100017510000000154115133166711022204 0ustar00runnerrunnername: 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=1768746441.0 pyicloud-2.3.0/.github/workflows/sonarcube.yml0000644000175100017510000000264515133166711021126 0ustar00runnerrunnername: SonarCloud.io permissions: contents: read pull-requests: write on: push: branches: - main pull_request: types: [opened, synchronize, reopened] jobs: coverage: name: Generate Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup Python uses: actions/setup-python@v6 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@v6 with: name: code-coverage path: coverage.xml sonarqube: name: SonarQube runs-on: ubuntu-latest needs: coverage steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Download coverage artifact uses: actions/download-artifact@v7 with: name: code-coverage path: . - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v7 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.github/workflows/tests.yml0000644000175100017510000000142115133166711020276 0ustar00runnerrunnername: Tests on: [workflow_dispatch, push, pull_request] permissions: contents: read pull-requests: write jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" steps: - uses: actions/checkout@v6 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@v6 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.gitignore0000644000175100017510000000150015133166711015002 0ustar00runnerrunner# 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 # 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 fetch_devices_*.py *.jpg /test*.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.pre-commit-config.yaml0000644000175100017510000000140515133166711017277 0ustar00runnerrunner# 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: v6.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.14.11 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pycqa/isort rev: 7.0.0 hooks: - id: isort ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9265504 pyicloud-2.3.0/.vscode/0000755000175100017510000000000015133166716014364 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.vscode/launch.json0000644000175100017510000000527115133166711016531 0ustar00runnerrunner{ // 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 Locate", "type": "debugpy", "request": "launch", "module": "pyicloud.cmdline", "args": [ "--username", "${input:username}", "--list", "-n", "--locate", "--debug" ], "justMyCode": false }, { "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}", "--llist", "-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=1768746441.0 pyicloud-2.3.0/.vscode/settings.json0000644000175100017510000000220415133166711017110 0ustar00runnerrunner{ "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": "fromEnvironment", "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": "fromEnvironment", "python.analysis.diagnosticMode": "openFilesOnly", "sonarlint.testFilePattern": "**/tests/**", "python.languageServer": "Pylance" } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/.vscode/tasks.json0000644000175100017510000000111615133166711016376 0ustar00runnerrunner{ "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=1768746441.0 pyicloud-2.3.0/LICENSE.txt0000644000175100017510000000207715133166711014647 0ustar00runnerrunnerThe 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=1768746445.9365506 pyicloud-2.3.0/PKG-INFO0000644000175100017510000007225715133166716014135 0ustar00runnerrunnerMetadata-Version: 2.4 Name: pyicloud Version: 2.3.0 Summary: PyiCloud is a module which allows pythonistas to interact with iCloud webservices. Author: The PyiCloud Authors License-Expression: MIT 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.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.10 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 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 the iCloud web application using your username and password, then performs regular queries against its API. **Please see the [terms of use](TERMS_OF_USE.md) for your responsibilities when using this library.** For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/nru3was4hk) ## 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 Enter iCloud password for jappleseed@apple.com: Save password in keyring? [y/N]: N ``` **Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. **Note**: Apple will require you to accept new terms and conditions to access the iCloud web service. This will result in login failures until the terms are accepted. This can be automatically accepted by PyiCloud using the `--accept-terms` command-line option. Alternatively you can visit the iCloud web site to view and accept the terms. ### 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) ``` ### Erase Device Erase Device functionality, forces the device to be erased when next connected to a network. It allows the person who picks up the phone to see 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 message = 'Thief! Return my phone immediately.' api.iphone.erase_device(message) ``` ## Calendar The calendar webservice supports fetching, creating, and removing calendars and events, with support for alarms, and invitees. ### 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 from pyicloud import PyiCloudService from pyicloud.services.calendar import CalendarObject api = PyiCloudService("username", "password") calendar_service = api.calendar cal = 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 with support for alarms and invitees. `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 required parameter. The `EventObject` includes automatic validation, dynamic timezone detection, and multiple methods for event management. #### Key Features - **Automatic Validation**: Events validate required fields, date ranges, and calendar GUIDs - **Dynamic Timezone Detection**: Automatically detects and uses the user's local timezone - **Alarm Support**: Add alarms at event time or before the event with flexible timing - **Invitee Management**: Add multiple invitees who will receive email notifications #### 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 referencing `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 specific 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`.* #### EventObject Methods **add_invitees(emails: list) -> None**
*Adds a list of email addresses as invitees to the event. They will receive email notifications when the event is created.* **add_alarm_at_time() -> str**
*Adds an alarm that triggers at the exact time of the event. Returns the alarm GUID for reference.* **add_alarm_before(minutes=0, hours=0, days=0, weeks=0) -> str**
*Adds an alarm that triggers before the event starts. You can specify any combination of time units. Returns the alarm GUID for reference.* #### Examples *Create an event with invitees and alarms:* ``` python from datetime import datetime, timedelta from pyicloud import PyiCloudService from pyicloud.services.calendar import EventObject api = PyiCloudService("username", "password") calendar_service = api.calendar # Get a calendar to use calendars = calendar_service.get_calendars(as_objs=True) calendar_guid = calendars[0].guid # Create an event with proper validation event = EventObject( pguid=calendar_guid, title="Team Meeting", start_date=datetime.now() + timedelta(hours=2), end_date=datetime.now() + timedelta(hours=3), location="Conference Room A", all_day=False ) # Add invitees (they'll receive email notifications) event.add_invitees(["colleague1@company.com", "colleague2@company.com"]) # Add alarms event.add_alarm_before(minutes=15) # 15 minutes before event.add_alarm_before(days=1) # 1 day before # Add the event to the calendar calendar_service.add_event(event) ``` *Create a simple event:* ``` python # Basic event creation event = EventObject( pguid=calendar_guid, title="Doctor Appointment", start_date=datetime(2024, 1, 15, 14, 0), end_date=datetime(2024, 1, 15, 15, 0) ) # Add a 30-minute warning alarm event.add_alarm_before(minutes=30) calendar_service.add_event(event) ``` *Get events in a specific date range:* ``` python from_dt = datetime(2024, 1, 1) to_dt = datetime(2024, 1, 31) events = calendar_service.get_events(from_dt, to_dt, as_objs=True) for event in events: print(f"Event: {event.title} at {event.start_date}") ``` *Get next week's events:* ``` python next_week_events = calendar_service.get_events( from_dt=datetime.today() + timedelta(days=7), period="week", as_objs=True ) ``` *Remove an event:* ``` python calendar_service.remove_event(event) ``` ## 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) - Legacy service You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: **NOTE** If you receive a `Account migrated` error, apple has migrated your account to iCloud drive. Please use the `api.drive` API instead. ``` 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'] ``` To delete an individual album, call the `delete` method. ``` pycon >>> api.photos.albums['MyAlbum'] >>> api.photos.albums['MyAlbum'].delete() True ``` 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 raw stream: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) with open(photo.filename, 'wb') as opened_file: opened_file.write(photo.download()) ``` 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 with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(photo.download('thumb')) ``` To upload a photo use the `upload` method, which will upload the file to the requested album this will appear automatically in your 'ALL PHOTOS' album. This will return the uploaded PhotoAsset for further information. ``` python api.photos.albums['Screenshots'].upload(file_path) ``` ``` pycon >>> album = api.photos.albums['Screenshots'] >>> album >>> album.upload("./my_test_image.jpg") my_test_image.jpg ``` Note: Only limited media types are accepted. Unsupported types (e.g., PNG) will return a TYPE_UNSUPPORTED error. To delete a photo, use the `delete` method on the PhotoAsset. It returns a bool indicating success. ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> photo.delete() True ``` To add an existing photo to an album, use the `add_photo` method, which will link the PhotoAsset to the requested album. It returns a bool indicating success. ``` python api.photos.albums['Screenshots'].add_photo(photo_asset) ``` ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> my_album = api.photos.albums['MyAlbum'] >>> my_album >>> my_album.add_photo(photo) True ``` ## 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=1768746441.0 pyicloud-2.3.0/README.md0000644000175100017510000006661015133166711014306 0ustar00runnerrunner# 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 the iCloud web application using your username and password, then performs regular queries against its API. **Please see the [terms of use](TERMS_OF_USE.md) for your responsibilities when using this library.** For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/nru3was4hk) ## 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 Enter iCloud password for jappleseed@apple.com: Save password in keyring? [y/N]: N ``` **Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. **Note**: Apple will require you to accept new terms and conditions to access the iCloud web service. This will result in login failures until the terms are accepted. This can be automatically accepted by PyiCloud using the `--accept-terms` command-line option. Alternatively you can visit the iCloud web site to view and accept the terms. ### 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) ``` ### Erase Device Erase Device functionality, forces the device to be erased when next connected to a network. It allows the person who picks up the phone to see 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 message = 'Thief! Return my phone immediately.' api.iphone.erase_device(message) ``` ## Calendar The calendar webservice supports fetching, creating, and removing calendars and events, with support for alarms, and invitees. ### 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 from pyicloud import PyiCloudService from pyicloud.services.calendar import CalendarObject api = PyiCloudService("username", "password") calendar_service = api.calendar cal = 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 with support for alarms and invitees. `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 required parameter. The `EventObject` includes automatic validation, dynamic timezone detection, and multiple methods for event management. #### Key Features - **Automatic Validation**: Events validate required fields, date ranges, and calendar GUIDs - **Dynamic Timezone Detection**: Automatically detects and uses the user's local timezone - **Alarm Support**: Add alarms at event time or before the event with flexible timing - **Invitee Management**: Add multiple invitees who will receive email notifications #### 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 referencing `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 specific 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`.* #### EventObject Methods **add_invitees(emails: list) -> None**
*Adds a list of email addresses as invitees to the event. They will receive email notifications when the event is created.* **add_alarm_at_time() -> str**
*Adds an alarm that triggers at the exact time of the event. Returns the alarm GUID for reference.* **add_alarm_before(minutes=0, hours=0, days=0, weeks=0) -> str**
*Adds an alarm that triggers before the event starts. You can specify any combination of time units. Returns the alarm GUID for reference.* #### Examples *Create an event with invitees and alarms:* ``` python from datetime import datetime, timedelta from pyicloud import PyiCloudService from pyicloud.services.calendar import EventObject api = PyiCloudService("username", "password") calendar_service = api.calendar # Get a calendar to use calendars = calendar_service.get_calendars(as_objs=True) calendar_guid = calendars[0].guid # Create an event with proper validation event = EventObject( pguid=calendar_guid, title="Team Meeting", start_date=datetime.now() + timedelta(hours=2), end_date=datetime.now() + timedelta(hours=3), location="Conference Room A", all_day=False ) # Add invitees (they'll receive email notifications) event.add_invitees(["colleague1@company.com", "colleague2@company.com"]) # Add alarms event.add_alarm_before(minutes=15) # 15 minutes before event.add_alarm_before(days=1) # 1 day before # Add the event to the calendar calendar_service.add_event(event) ``` *Create a simple event:* ``` python # Basic event creation event = EventObject( pguid=calendar_guid, title="Doctor Appointment", start_date=datetime(2024, 1, 15, 14, 0), end_date=datetime(2024, 1, 15, 15, 0) ) # Add a 30-minute warning alarm event.add_alarm_before(minutes=30) calendar_service.add_event(event) ``` *Get events in a specific date range:* ``` python from_dt = datetime(2024, 1, 1) to_dt = datetime(2024, 1, 31) events = calendar_service.get_events(from_dt, to_dt, as_objs=True) for event in events: print(f"Event: {event.title} at {event.start_date}") ``` *Get next week's events:* ``` python next_week_events = calendar_service.get_events( from_dt=datetime.today() + timedelta(days=7), period="week", as_objs=True ) ``` *Remove an event:* ``` python calendar_service.remove_event(event) ``` ## 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) - Legacy service You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: **NOTE** If you receive a `Account migrated` error, apple has migrated your account to iCloud drive. Please use the `api.drive` API instead. ``` 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'] ``` To delete an individual album, call the `delete` method. ``` pycon >>> api.photos.albums['MyAlbum'] >>> api.photos.albums['MyAlbum'].delete() True ``` 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 raw stream: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) with open(photo.filename, 'wb') as opened_file: opened_file.write(photo.download()) ``` 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 with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(photo.download('thumb')) ``` To upload a photo use the `upload` method, which will upload the file to the requested album this will appear automatically in your 'ALL PHOTOS' album. This will return the uploaded PhotoAsset for further information. ``` python api.photos.albums['Screenshots'].upload(file_path) ``` ``` pycon >>> album = api.photos.albums['Screenshots'] >>> album >>> album.upload("./my_test_image.jpg") my_test_image.jpg ``` Note: Only limited media types are accepted. Unsupported types (e.g., PNG) will return a TYPE_UNSUPPORTED error. To delete a photo, use the `delete` method on the PhotoAsset. It returns a bool indicating success. ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> photo.delete() True ``` To add an existing photo to an album, use the `add_photo` method, which will link the PhotoAsset to the requested album. It returns a bool indicating success. ``` python api.photos.albums['Screenshots'].add_photo(photo_asset) ``` ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> my_album = api.photos.albums['MyAlbum'] >>> my_album >>> my_album.add_photo(photo) True ``` ## 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=1768746441.0 pyicloud-2.3.0/TERMS_OF_USE.md0000644000175100017510000000725515133166711015343 0ustar00runnerrunner# Terms of Use ## PyiCloud Library - Personal Use Recommendations ### 1. Acceptance of Terms By using, downloading, or accessing the PyiCloud library ("the Software"), you acknowledge that you have read, understood, and agree to consider these Terms of Use. If you have concerns with these recommendations, please review the project documentation and the MIT License before proceeding. ### 2. Recommended Use It is **recommended** that you use this Software for personal purposes only. Suggested uses include: - Accessing your own personal iCloud data - Managing your own iCloud services for legitimate personal purposes - Developing personal applications that interact with your own iCloud account ### 3. Recommended Restrictions We **recommend** that you do **not** use this Software to: - Access iCloud accounts that do not belong to you - Violate Apple's Terms of Service or iCloud Terms and Conditions - Circumvent any security measures implemented by Apple - Engage in any unauthorized access to Apple's services - Use the Software for commercial purposes unless you are sure your use complies with the MIT License and Apple's terms - Distribute, sell, or sublicense access to iCloud services through this Software unless permitted by the MIT License and Apple - Engage in any activity that could harm Apple's infrastructure or services ### 4. Compliance with Apple's Terms **IMPORTANT**: We recommend that your use of this Software complies with all applicable Apple Terms of Service, including but not limited to: - Apple's iCloud Terms and Conditions - Apple's Software License Agreements - Apple's Privacy Policy - Any other terms governing Apple's services You are solely responsible for ensuring your use of this Software does not violate any of Apple's terms and conditions. The developers of this Software are not responsible for any violations of Apple's terms that may result from your use of this library. ### 5. User Responsibility You acknowledge and agree that: - You are solely responsible for your use of the Software - You should use the Software to access only your own iCloud account(s) - You should not use the Software to violate any laws, regulations, or third-party rights - You understand that Apple may modify their services, APIs, or terms at any time - You should discontinue use if Apple objects to your use of this Software ### 6. Risk and Liability **USE AT YOUR OWN RISK**: This Software is provided as-is, without any warranties. The developers are not responsible for: - Any consequences resulting from your use of the Software - Any violations of Apple's terms and conditions - Any loss of data or service interruptions - Any legal issues that may arise from your use of the Software ### 7. No Endorsement by Apple This Software is not affiliated with, endorsed by, or sponsored by Apple Inc. Apple has not reviewed or approved this Software. Use of this Software may void warranties or violate terms of service with Apple. ### 8. Modifications to Terms These Terms of Use may be updated from time to time. Continued use of the Software after any modifications constitutes acceptance of the updated recommendations. ### 9. Termination Your right to use this Software may be restricted if you violate Apple’s terms and conditions. ### 10. Contact If you have questions about these Terms of Use, please review the project documentation and consider the legal implications of your intended use before proceeding. --- **DISCLAIMER**: This is an independent, open-source project. Users must ensure their usage complies with all applicable laws and Apple's terms of service. The maintainers of this project are not responsible for any misuse of the Software. **Last Updated**: September 24, 2025 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/examples.py0000755000175100017510000004003215133166711015210 0ustar00runnerrunner#!/usr/bin/env python """End to End System test""" import argparse import http.client import json import logging import sys from datetime import datetime, timedelta from pathlib import Path from typing import Any, List, Optional from unittest.mock import patch 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 from pyicloud.services.photos import BasePhotoAlbum, PhotoAlbum, PhotoAsset from pyicloud.ssl_context import configurable_ssl_verification END_LIST: str = "End List\n" MAX_DISPLAY: int = 10 # Set to FALSE to disable SSL verification to use tools like charles, mitmproxy, fiddler, or similiar tools to debug the data sent on the wire. # Can also use command-line argument --disable-ssl-verify # This uses code taken from: # - https://stackoverflow.com/questions/15445981/how-do-i-disable-the-security-certificate-check-in-python-requests # - https://stackoverflow.com/questions/16337511/log-all-requests-from-the-python-requests-module ENABLE_SSL_VERIFICATION: bool = True # Set the log level for HTTP commands HTTP_LOG_LEVEL: int = logging.ERROR # Set the log level for other commands OTHER_LOG_LEVEL: int = logging.ERROR # HTTPConnection parameters HTTPCONNECTION_DEBUG_INFO: bool = False HTTP_PROXY: Optional[str] = None HTTPS_PROXY: Optional[str] = None # Set where you'd like the COOKIES to be stored. Can also use command-line argument --cookie-dir COOKIE_DIR: str = "" # location to store session information # Other configurable variables APPLE_USERNAME: str = "" APPLE_PASSWORD: str = "" CHINA: bool = False def parse_args() -> None: """Parse command line arguments""" global ENABLE_SSL_VERIFICATION, COOKIE_DIR, APPLE_PASSWORD, APPLE_USERNAME, CHINA, HTTP_PROXY, HTTPS_PROXY # pylint: disable=global-statement 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( "--cookie-dir", action="store", dest="cookie_directory", default="", help="Directory to store session information and cookies", ) 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( "--disable-ssl-verify", action="store_true", dest="disable_ssl", default=False, help="Disable SSL verification", ) parser.add_argument( "--http-proxy", type=str, help="Use HTTP proxy for requests", ) parser.add_argument( "--https-proxy", type=str, help="Use HTTPS proxy for requests", ) if len(sys.argv) == 1: parser.print_help(sys.stderr) sys.exit(1) args: argparse.Namespace = parser.parse_args() if not args.username: parser.error("Both --username is required") else: APPLE_USERNAME = args.username APPLE_PASSWORD = args.password or "" if args.cookie_directory: COOKIE_DIR = args.cookie_directory if args.china_mainland: CHINA = args.china_mainland if args.disable_ssl or not ENABLE_SSL_VERIFICATION: ENABLE_SSL_VERIFICATION = False print("=" * 80) print("⚠️ SECURITY WARNING: SSL VERIFICATION DISABLED ⚠️") print("This is insecure and should ONLY be used for debugging!") print("Your credentials and data may be exposed to attackers.") print("=" * 80) print() if args.http_proxy: HTTP_PROXY = args.http_proxy if args.https_proxy: HTTPS_PROXY = args.https_proxy def httpclient_logging_patch(level=HTTP_LOG_LEVEL) -> None: """Enable HTTPConnection debug logging to the logging framework""" httpclient_logger: logging.Logger = logging.getLogger("http.client") httpclient_logger.setLevel(level) def httpclient_log(*args) -> None: httpclient_logger.log(level, " ".join(map(str, args))) # mask the print() built-in in the http.client module to use # logging instead patch("http.client.print", httpclient_log).start() # enable debugging if HTTPCONNECTION_DEBUG_INFO: http.client.HTTPConnection.debuglevel = 1 else: http.client.HTTPConnection.debuglevel = 0 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 if not fido2_devices: print("No FIDO2 devices detected. Connect a security key and try again.") sys.exit(1) 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 if not trusted_devices: print("No trusted devices are available for 2-step verification.") sys.exit(1) 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?", type=click.IntRange(0, len(trusted_devices) - 1), 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 authenticated PyiCloudService instance""" api = PyiCloudService( apple_id=APPLE_USERNAME, password=APPLE_PASSWORD, china_mainland=CHINA, cookie_directory=COOKIE_DIR, ) 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) if not calendars: return # Get recent events from API try: recent_events = calendar_service.get_events( from_dt=datetime.now() - timedelta(days=7), to_dt=datetime.now() + timedelta(days=7), as_objs=True, ) print(f"Recent events (±7 days): {len(recent_events)} events") for idx, event in enumerate(recent_events): if hasattr(event, "title") and hasattr(event, "start_date"): print(f"\t{idx}: {event.title} at {event.start_date}") if idx >= MAX_DISPLAY - 1: break print(END_LIST) except Exception as e: print(f"Could not retrieve events: {e}\n") def display_contacts(api: PyiCloudService) -> None: """Display contacts info""" contacts: List[dict[str, Any]] | None = 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: data: bytes | None = photo.download() if data: print(f"\t\tDownloaded {len(data)} bytes") else: print("\t\tDownload failed") break print(END_LIST) def display_videos(api: PyiCloudService) -> None: """Display video info""" if "Videos" in api.photos.albums: 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) else: print("No 'Videos' album found") def display_shared_photos(api: PyiCloudService) -> None: """Display shared photo info""" selected_album: BasePhotoAlbum | None = next(iter(api.photos.shared_streams), 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.name} ({len(album)} photos)") if idx >= MAX_DISPLAY - 1: break print(END_LIST) if selected_album and api.photos.shared_streams: print(f"List of Shared Photos [{selected_album.name}] ({len(selected_album)}):") for idx, photo in enumerate(selected_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}") try: 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']}") except Exception as e: print(f"\t\tPhoto: Error retrieving user photo: {e}") 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 album_management(api: PyiCloudService) -> None: """Test album management functions""" album_name = "Test Album from API" print(f"Creating album '{album_name}'...") album: PhotoAlbum | None = api.photos.create_album(album_name) print(f"Album created: {album}") if album is None: print("Album creation failed.") return print(f"Album '{album_name}' created successfully.") album.name = "Renamed Album" print(f"Album renamed to '{album.name}'") sample_photo: Path = Path(__file__).with_name("sample.jpg") if sample_photo.exists(): photo: PhotoAsset | None = album.upload(str(sample_photo)) if photo: print(f"Photo uploaded successfully: {photo.filename} ({photo.item_type})") if photo.delete(): print("Photo deleted successfully.") else: print("Photo upload failed.") else: print(f"Skipping upload: sample photo not found at {sample_photo}") print(f"Deleting album '{album.name}'...") if album.delete(): print("Album deleted.") else: print("Album deletion failed.") def setup() -> None: """Setup""" parse_args() # Enable general debug logging logging.basicConfig(level=OTHER_LOG_LEVEL) # Enable httpclient logging httpclient_logging_patch() def main() -> None: """main function""" with configurable_ssl_verification( ENABLE_SSL_VERIFICATION, HTTP_PROXY, HTTPS_PROXY, ): api: PyiCloudService = get_api() display_account(api) display_devices(api) display_hidemyemail(api) try: display_calendars(api) except PyiCloudServiceUnavailable as error: print(f"Calendar service not available: {error}\n") display_files(api) display_contacts(api) display_drive(api) display_photos(api) display_videos(api) display_shared_photos(api) album_management(api) if __name__ == "__main__": setup() main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9275506 pyicloud-2.3.0/pyicloud/0000755000175100017510000000000015133166716014653 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9285505 pyicloud-2.3.0/pyicloud/.vscode/0000755000175100017510000000000015133166716016214 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/.vscode/launch.json0000644000175100017510000000075015133166711020356 0ustar00runnerrunner{ // 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=1768746441.0 pyicloud-2.3.0/pyicloud/__init__.py0000644000175100017510000000035015133166711016755 0ustar00runnerrunner"""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=1768746441.0 pyicloud-2.3.0/pyicloud/base.py0000644000175100017510000010552215133166711016137 0ustar00runnerrunner"""Library base file.""" import base64 import getpass import json import logging import time 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 DefaultClientDataCollector, Fido2Client from fido2.hid import CtapHidDevice from fido2.webauthn import ( AuthenticationResponse, PublicKeyCredentialDescriptor, PublicKeyCredentialRequestOptions, PublicKeyCredentialType, UserVerificationRequirement, ) from requests import HTTPError from requests.models import Response from pyicloud.const import ACCOUNT_NAME, CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT from pyicloud.exceptions import ( PyiCloud2FARequiredException, PyiCloudAcceptTermsException, PyiCloudAPIResponseException, PyiCloudFailedLoginException, PyiCloudNoTrustedNumberAvailable, PyiCloudPasswordException, PyiCloudServiceNotActivatedException, PyiCloudServiceUnavailable, ) from pyicloud.services import ( AccountService, AppleDevice, CalendarService, ContactsService, DriveService, FindMyiPhoneServiceManager, HideMyEmailService, PhotosService, RemindersService, UbiquityService, ) from pyicloud.session import PyiCloudSession from pyicloud.srp_password import SrpPassword, SrpProtocolType from pyicloud.utils import ( b64_encode, b64url_decode, get_password_from_keyring, ) LOGGER: logging.Logger = logging.getLogger(__name__) PCS_SLEEP_TIME: int = 5 PCS_MAX_RETRIES: int = 10 _HEADERS: dict[str, str] = { "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" ), } _AUTH_HEADERS_JSON: dict[str, str] = { "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": "", "X-Apple-Widget-Key": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", "X-Apple-FD-Client-Info": json.dumps( { "U": _HEADERS["User-Agent"], "L": "en-US", "Z": "GMT+00:00", "V": "1.1", "F": "", }, separators=(",", ":"), ), } _PARAMS: dict[str, str] = { "clientBuildNumber": "2534Project66", "clientMasteringNumber": "2534B22", } class PyiCloudService: """ 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._idmsa_endpoint: str = f"https://idmsa.apple.com{icloud_china}" self._auth_endpoint: str = f"{self._idmsa_endpoint}/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, accept_terms: bool = False, ) -> None: self._is_china_mainland: bool = ( china_mainland or environ.get("icloud_china", "0") == "1" ) self._setup_endpoints() self._password_raw: Optional[str] = password self._apple_id: str = apple_id self._accept_terms: bool = accept_terms if self._password_raw is None: self._password_raw = get_password_from_keyring(apple_id) self.data: dict[str, Any] = {} self._auth_data: dict[str, Any] = {} self.params: dict[str, Any] = {} self._client_id: str = client_id or str(uuid1()).lower() self._with_family: bool = with_family _cookie_directory: str = self._setup_cookie_directory(cookie_directory) _headers: dict[str, str] = _HEADERS.copy() _headers.update( { "Origin": self._home_endpoint, "Referer": f"{self._home_endpoint}/", } ) self._session: PyiCloudSession = PyiCloudSession( self, verify=verify, headers=_headers, client_id=self._client_id, cookie_directory=_cookie_directory, ) self._client_id = self.session.data.get("client_id", self._client_id) _params: dict[str, str] = _PARAMS.copy() _params.update( { "clientId": self._client_id, } ) self.params = _params 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._requires_mfa: bool = False 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 self.data.get("apps") 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 PyiCloudFailedLoginException: LOGGER.debug( "Could not log into service. Attempting brand new login." ) if not login_successful: try: self._authenticate() LOGGER.debug("Authentication completed successfully") except PyiCloud2FARequiredException: self._requires_mfa = True LOGGER.debug("2FA is required") self._update_state() def _handle_accept_terms(self, login_data: dict) -> None: """Handle accepting updated terms of service.""" if self.data.get("termsUpdateNeeded"): if not self._accept_terms: raise PyiCloudAcceptTermsException( "You must accept the updated terms of service to continue. " "Set --accept-terms to accept them." ) resp: Response = self.session.get( f"{self._setup_endpoint}/getTerms", params=self.params, json={ "locale": self.data.get("dsInfo", {}).get("languageCode", "en_US") }, ) resp.raise_for_status() terms_info: dict[str, Any] = resp.json() version: int | None = terms_info.get("iCloudTerms", {}).get("version") if version is None: raise PyiCloudAcceptTermsException("Could not get terms version") resp = self.session.get( f"{self._setup_endpoint}/repairDone", params=self.params, json={"acceptedICloudTerms": version}, ) resp.raise_for_status() resp = self.session.post( f"{self._setup_endpoint}/accountLogin", json=login_data ) resp.raise_for_status() self.data = resp.json() 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"] def _authenticate(self) -> None: LOGGER.debug("Authenticating as %s", self.account_name) try: self._authenticate_with_token() except (PyiCloudFailedLoginException, PyiCloud2FARequiredException): self._srp_authentication() self._authenticate_with_token() def _srp_authentication(self) -> None: """SRP authentication.""" if self._password_raw is None: raise PyiCloudFailedLoginException("No password set") auth_headers = self._get_auth_headers() response: Response = self.session.get( f"{self._auth_endpoint}/authorize/signin", params={ "frame_id": auth_headers["X-Apple-OAuth-State"], "skVersion": "7", "iframeid": auth_headers["X-Apple-OAuth-State"], "client_id": auth_headers["X-Apple-Widget-Key"], "response_type": auth_headers["X-Apple-OAuth-Response-Type"], "redirect_uri": auth_headers["X-Apple-OAuth-Redirect-URI"], "response_mode": auth_headers["X-Apple-OAuth-Response-Mode"], "state": auth_headers["X-Apple-OAuth-State"], "authVersion": "latest", }, ) response.raise_for_status() srp_password: SrpPassword = SrpPassword(self._password_raw) srp.rfc5054_enable() srp.no_username_in_x() try: 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": [protocol.value for protocol in SrpProtocolType], } response: Response = self.session.post( f"{self._auth_endpoint}/signin/init", json=data, headers=self._get_auth_headers(), ) response.raise_for_status() except ( PyiCloudAPIResponseException, HTTPError, PyiCloudPasswordException, ) as error: msg = "Failed to initiate srp authentication." raise PyiCloudFailedLoginException(msg, error) from error body: dict[str, Any] = response.json() salt: bytes = base64.b64decode(body["salt"]) b: bytes = base64.b64decode(body["b"]) c: Any = body["c"] iterations: int = body["iteration"] protocol: SrpProtocolType = SrpProtocolType(body["protocol"]) key_length: int = 32 srp_password.set_encrypt_info(salt, iterations, key_length, protocol) m1: None | Any = usr.process_challenge(salt, b) m2: None | bytes = 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=self._get_auth_headers(), ) except PyiCloud2FARequiredException: LOGGER.debug("2FA required to complete authentication.") self._auth_data = self._get_mfa_auth_options() 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: login_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=login_data ) resp.raise_for_status() self.data = resp.json() self._handle_accept_terms(login_data) 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.""" login_data: dict[str, Any] = { "appName": service, "apple_id": self.account_name, "password": self._password_raw, } try: self.session.post(f"{self._setup_endpoint}/accountLogin", json=login_data) self._handle_accept_terms(login_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] = _AUTH_HEADERS_JSON.copy() headers.update( { "Referer": self._idmsa_endpoint, "X-Apple-OAuth-State": self._client_id, "X-Apple-Frame-Id": self._client_id, } ) 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 self.session.data.get("auth_attributes"): headers["X-Apple-Auth-Attributes"] = self.session.data["auth_attributes"] if overrides: headers.update(overrides) return headers @property def session(self) -> PyiCloudSession: """Return the session.""" return self._session def _is_mfa_required(self) -> bool: return ( self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session or self._requires_mfa ) @property def requires_2sa(self) -> bool: """Returns True if two-step authentication is required.""" return ( self._is_mfa_required() and self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 ) @property def requires_2fa(self) -> bool: """Returns True if two-factor authentication is required.""" return ( self._is_mfa_required() and self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2 ) @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_mfa_auth_options(self) -> Dict: """Retrieve auth request options 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._auth_data.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.""" fsa: dict[str, Any] = self._auth_data.get("fsaChallenge", {}) try: challenge = fsa["challenge"] allowed_credentials = fsa["keyHandles"] rp_id = fsa["rpId"] except KeyError as error: raise PyiCloudAPIResponseException( "Missing WebAuthn challenge data" ) from error 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, client_data_collector=DefaultClientDataCollector("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, user_verification=UserVerificationRequirement("discouraged"), ) result: AuthenticationResponse = client.get_assertion( assertion_options ).get_response(0) self._submit_webauthn_assertion_response( { "challenge": challenge, "clientData": b64_encode(result.response.client_data), "signatureData": b64_encode(result.response.signature), "authenticatorData": b64_encode(result.response.authenticator_data), "userHandle": b64_encode(result.response.user_handle) if result.response.user_handle else None, "credentialID": b64_encode(result.raw_id), "rpId": rp_id, } ) self.trust_session() def _check_pcs_consent(self) -> dict[str, Any]: """Check if the user has consented to PCS access.""" LOGGER.debug("Querying web access state") resp = self.session.post( f"{self._setup_endpoint}/requestWebAccessState", params=self.params ).json() return resp def _send_pcs_request( self, app_name: str, derived_from_user_action: bool ) -> dict[str, Any]: """Send a request to the PCS endpoint to check the status of PCS access.""" LOGGER.debug("Querying PCS status") return self.session.post( f"{self._setup_endpoint}/requestPCS", json={ "appName": app_name, "derivedFromUserAction": derived_from_user_action, }, params=self.params, ).json() def _request_pcs_for_service(self, app_name: str) -> None: """Request PCS access for a specific service.""" _check_pcs_resp: dict[str, Any] = self._check_pcs_consent() if not _check_pcs_resp.get("isICDRSDisabled", False): LOGGER.warning("ICDRS is not disabled") return if not _check_pcs_resp.get("isDeviceConsentedForPCS", True): LOGGER.debug("Requesting PCS consent") resp = self.session.post( f"{self._setup_endpoint}/enableDeviceConsentForPCS", params=self.params ).json() if not resp.get("isDeviceConsentNotificationSent"): raise PyiCloudAPIResponseException("Unable to request PCS access!") LOGGER.debug("Waiting for PCS consent") for _ in range(PCS_MAX_RETRIES): if _check_pcs_resp.get("isDeviceConsentedForPCS", True): LOGGER.debug("PCS consent granted") break LOGGER.debug("PCS consent not granted yet, waiting...") time.sleep(PCS_SLEEP_TIME) _check_pcs_resp = self._check_pcs_consent() for attempt in range(PCS_MAX_RETRIES): resp: dict[str, Any] = self._send_pcs_request( app_name, derived_from_user_action=attempt == 0, ) if resp["status"] == "success": LOGGER.debug("PCS access was granted") return if resp["message"] in ( "Requested the device to upload cookies.", "Cookies not available yet on server.", ): LOGGER.debug("PCS access couldn't be obtained: %s", resp["message"]) time.sleep(PCS_SLEEP_TIME) else: LOGGER.error("Unknown PCS state: %s", resp["message"]) raise PyiCloudAPIResponseException("Unable to request PCS access!") def validate_2fa_code(self, code: str) -> bool: """Verifies a verification code received via Apple's 2FA system (HSA2).""" try: if self._auth_data.get("mode") == "sms": self._validate_sms_code(code) else: data: dict[str, Any] = {"securityCode": {"code": code}} headers: dict[str, Any] = self._get_auth_headers( {"Accept": CONTENT_TYPE_JSON} ) 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 _validate_sms_code(self, code: str) -> None: """Verifies a verification code received via Apple's SMS system.""" trusted_phone_number: dict[str, Any] | None = self._auth_data.get( "trustedPhoneNumber" ) if not trusted_phone_number: raise PyiCloudNoTrustedNumberAvailable() device_id: int | None = trusted_phone_number.get("id") non_fteu: bool | None = trusted_phone_number.get("nonFTEU") mode: str | None = trusted_phone_number.get("pushMode") data: dict[str, Any] = { "phoneNumber": {"id": device_id, "nonFTEU": non_fteu}, "securityCode": {"code": code}, "mode": mode, } headers: dict[str, Any] = self._get_auth_headers( {"Accept": f"{CONTENT_TYPE_JSON}, {CONTENT_TYPE_TEXT}"} ) self.session.post( f"{self._auth_endpoint}/verify/phone/securitycode", json=data, headers=headers, ) def trust_session(self) -> bool: """Request session trust to avoid user log in going forward.""" self._requires_mfa = False headers: dict[str, Any] = self._get_auth_headers() try: self.session.get( f"{self._auth_endpoint}/2sv/trust", headers=headers, ) self._authenticate_with_token() LOGGER.debug("Session trust successful.") 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=service_root, token_endpoint=self._setup_endpoint, session=self.session, params=self.params, with_family=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=service_root, session=self.session, params=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=service_root, session=self.session, params=self.params, ) except (PyiCloudAPIResponseException,) as error: if "Account migrated" == error.reason: raise PyiCloudServiceUnavailable( "Files service not available use `api.drive` instead" ) from error raise PyiCloudServiceUnavailable( "Files service not available" ) from error return self._files @property def photos(self) -> PhotosService: """Gets the 'Photo' service.""" self._request_pcs_for_service("photos") 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=service_root, session=self.session, params=self.params, upload_url=upload_url, shared_streams_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=service_root, session=self.session, params=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=service_root, session=self.session, params=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=service_root, session=self.session, params=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.""" self._request_pcs_for_service("iclouddrive") 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 def __str__(self) -> str: return f"iCloud API: {self.account_name}" def __repr__(self) -> str: return f"<{self}>" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/cmdline.py0000644000175100017510000003472615133166711016647 0ustar00runnerrunner#! /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 os import pickle import sys from pprint import pformat from typing import Any, Optional from click import confirm from pyicloud import PyiCloudService, utils from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable from pyicloud.services.findmyiphone import AppleDevice from pyicloud.ssl_context import configurable_ssl_verification DEVICE_ERROR = "Please use the --device switch to indicate which device to use." def create_pickled_data(idevice: AppleDevice, filename: str) -> None: """ This helper will output the idevice to a pickled file named after the passed filename. This allows the data to be used without resorting to screen / pipe scrapping. """ with open(filename, "wb") as pickle_file: pickle.dump( idevice.data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL, ) def _create_parser() -> argparse.ArgumentParser: """Create the parser.""" parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool") parser.add_argument( "--username", action="store", dest="username", default="", help="Apple ID to Use", ) parser.add_argument( "--password", action="store", dest="password", default="", help=( "Apple ID Password to Use; if unspecified, password will be " "fetched from the system keyring." ), ) parser.add_argument( "--china-mainland", action="store_true", dest="china_mainland", default=False, help="If the country/region setting of the Apple ID is China mainland", ) parser.add_argument( "-n", "--non-interactive", action="store_false", dest="interactive", default=True, help="Disable interactive prompts.", ) parser.add_argument( "--delete-from-keyring", action="store_true", dest="delete_from_keyring", default=False, help="Delete stored password in system keyring for this username.", ) # Group for listing options list_group = parser.add_argument_group( title="Listing Options", description="Options for listing devices", ) # Mutually exclusive group for listing options list_type_group = list_group.add_mutually_exclusive_group() list_type_group.add_argument( "--list", action="store_true", dest="list", default=False, help="Short Listings for Device(s) associated with account", ) list_type_group.add_argument( "--llist", action="store_true", dest="longlist", default=False, help="Detailed Listings for Device(s) associated with account", ) list_group.add_argument( "--locate", action="store_true", dest="locate", default=False, help="Retrieve Location for the iDevice (non-exclusive).", ) # Restrict actions to a specific devices UID / DID parser.add_argument( "--device", action="store", dest="device_id", default=False, help="Only effect this device", ) # Trigger Sound Alert parser.add_argument( "--sound", action="store_true", dest="sound", default=False, help="Play a sound on the device", ) # Trigger Message w/Sound Alert parser.add_argument( "--message", action="store", dest="message", default=False, help="Optional Text Message to display with a sound", ) # Trigger Message (without Sound) Alert parser.add_argument( "--silentmessage", action="store", dest="silentmessage", default=False, help="Optional Text Message to display with no sounds", ) # Lost Mode parser.add_argument( "--lostmode", action="store_true", dest="lostmode", default=False, help="Enable Lost mode for the device", ) parser.add_argument( "--lostphone", action="store", dest="lost_phone", default=False, help="Phone Number allowed to call when lost mode is enabled", ) parser.add_argument( "--lostpassword", action="store", dest="lost_password", default=False, help="Forcibly active this passcode on the idevice", ) parser.add_argument( "--lostmessage", action="store", dest="lost_message", default="", help="Forcibly display this message when activating lost mode.", ) # Output device data to an pickle file parser.add_argument( "--outputfile", action="store_true", dest="output_to_file", default="", help="Save device data to a file in the current directory.", ) parser.add_argument( "--log-level", action="store", dest="loglevel", choices=["error", "warning", "info", "none"], default="info", help="Set the logging level", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) parser.add_argument( "--accept-terms", action="store_true", default=False, help="Automatically accept terms and conditions", ) parser.add_argument( "--with-family", action="store_true", default=False, help="Include family devices", ) parser.add_argument( "--session-dir", type=str, help="Directory to store session files", ) parser.add_argument( "--http-proxy", type=str, help="Use HTTP proxy for requests", ) parser.add_argument( "--https-proxy", type=str, help="Use HTTPS proxy for requests", ) parser.add_argument( "--no-verify-ssl", action="store_true", default=False, help="Disable SSL certificate verification (WARNING: This makes the connection insecure)", ) return parser def _get_password( username: str, parser: argparse.ArgumentParser, command_line: argparse.Namespace, ) -> Optional[str]: """Which password we use is determined by your username, so we do need to check for this first and separately.""" if not username: parser.error("No username supplied") password: Optional[str] = command_line.password if not password: password = utils.get_password(username, interactive=command_line.interactive) return password def main() -> None: """Main commandline entrypoint.""" parser: argparse.ArgumentParser = _create_parser() command_line: argparse.Namespace = parser.parse_args() level = logging.INFO if command_line.loglevel == "error": level = logging.ERROR elif command_line.loglevel == "warning": level = logging.WARNING elif command_line.loglevel == "info": level = logging.INFO elif command_line.loglevel == "none": level = None if command_line.debug: level = logging.DEBUG if level: logging.basicConfig(level=level) username: str = command_line.username.strip() china_mainland: bool = command_line.china_mainland if username and command_line.delete_from_keyring: utils.delete_password_in_keyring(username) with configurable_ssl_verification( verify_ssl=not command_line.no_verify_ssl, http_proxy=command_line.http_proxy or "", https_proxy=command_line.https_proxy or "", ): password: Optional[str] = _get_password(username, parser, command_line) api: Optional[PyiCloudService] = _authenticate( username, password, china_mainland, parser, command_line, ) if not api: return _print_devices(api, command_line) def _authenticate( username: str, password: Optional[str], china_mainland: bool, parser: argparse.ArgumentParser, command_line: argparse.Namespace, ) -> Optional[PyiCloudService]: api = None try: api = PyiCloudService( apple_id=username, password=password, china_mainland=china_mainland, cookie_directory=command_line.session_dir, accept_terms=command_line.accept_terms, with_family=command_line.with_family, ) if ( not utils.password_exists_in_keyring(username) and command_line.interactive and confirm("Save password in keyring?") and password ): utils.store_password_in_keyring(username, password) if api.requires_2fa: _handle_2fa(api) elif api.requires_2sa: _handle_2sa(api) return api except PyiCloudFailedLoginException as err: # If they have a stored password; we just used it and # it did not work; let's delete it if there is one. if not password: parser.error("No password supplied") if utils.password_exists_in_keyring(username): utils.delete_password_in_keyring(username) message: str = f"Bad username or password for {username}" print(err, file=sys.stderr) raise RuntimeError(message) from err def _print_devices(api: PyiCloudService, command_line: argparse.Namespace) -> None: try: print(f"Number of devices: {len(api.devices)}", flush=True) for dev in api.devices: if not command_line.device_id or ( command_line.device_id.strip().lower() == dev.id.strip().lower() ): # List device(s) _list_devices_option(command_line, dev) # Play a Sound on a device _play_device_sound_option(command_line, dev) # Display a Message on the device _display_device_message_option(command_line, dev) # Display a Silent Message on the device _display_device_silent_message_option(command_line, dev) # Enable Lost mode _enable_lost_mode_option(command_line, dev) except PyiCloudServiceUnavailable: print("iCloud - Find My service is unavailable.") def _enable_lost_mode_option( command_line: argparse.Namespace, dev: AppleDevice ) -> None: if command_line.lostmode: if command_line.device_id: dev.lost_device( number=command_line.lost_phone.strip(), text=command_line.lost_message.strip(), newpasscode=command_line.lost_password.strip(), ) else: raise RuntimeError( f"Lost Mode can only be activated on a singular device. {DEVICE_ERROR}" ) def _display_device_silent_message_option( command_line: argparse.Namespace, dev: AppleDevice ) -> None: if command_line.silentmessage: if command_line.device_id: dev.display_message( subject="A Silent Message", message=command_line.silentmessage, sounds=False, ) else: raise RuntimeError( f"Silent Messages can only be played on a singular device. {DEVICE_ERROR}" ) def _display_device_message_option( command_line: argparse.Namespace, dev: AppleDevice ) -> None: if command_line.message: if command_line.device_id: dev.display_message( subject="A Message", message=command_line.message, sounds=True ) else: raise RuntimeError( f"Messages can only be played on a singular device. {DEVICE_ERROR}" ) def _play_device_sound_option( command_line: argparse.Namespace, dev: AppleDevice ) -> None: if command_line.sound: if command_line.device_id: dev.play_sound() else: raise RuntimeError( f"\n\n\t\tSounds can only be played on a singular device. {DEVICE_ERROR}\n\n" ) def _list_devices_option(command_line: argparse.Namespace, dev: AppleDevice) -> None: location = dev.location if command_line.locate else None if command_line.output_to_file: create_pickled_data( dev, filename=(dev.name.strip().lower() + ".fmip_snapshot"), ) if command_line.longlist: print("-" * 30) print(dev.name) for key in dev.data: print( f"{key:>30} - {pformat(dev.data[key]).replace(os.linesep, os.linesep + ' ' * 33)}" ) elif command_line.list: print("-" * 30) print(f"Name - {dev.name}") print(f"Display Name - {dev.deviceDisplayName}") print(f"Location - {location or dev.location}") print(f"Battery Level - {dev.batteryLevel}") print(f"Battery Status - {dev.batteryStatus}") print(f"Device Class - {dev.deviceClass}") print(f"Device Model - {dev.deviceModel}") def _handle_2fa(api: PyiCloudService) -> None: print("\nTwo-step authentication required.", "\nPlease enter validation code") code: str = input("(string) --> ") if not api.validate_2fa_code(code): print("Failed to verify verification code") sys.exit(1) print("") def _handle_2sa(api: PyiCloudService) -> None: print("\nTwo-step authentication required.", "\nYour trusted devices are:") devices: list[dict[str, Any]] = _show_devices(api) print("\nWhich device would you like to use?") device_idx = int(input("(number) --> ")) device: dict[str, Any] = devices[device_idx] if not api.send_verification_code(device): print("Failed to send verification code") sys.exit(1) print("\nPlease enter validation code") code: str = input("(string) --> ") if not api.validate_verification_code(device, code): print("Failed to verify verification code") sys.exit(1) print("") def _show_devices(api: PyiCloudService) -> list[dict[str, Any]]: """Show devices.""" devices: list[dict[str, Any]] = api.trusted_devices for i, device in enumerate(devices): phone_number: str = f"{device.get('deviceType')} to {device.get('phoneNumber')}" print(f" {i}: {device.get('deviceName', phone_number)}") return devices if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/const.py0000644000175100017510000000202315133166711016343 0ustar00runnerrunner"""Contants for the PyiCloud API.""" from enum import IntEnum 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-Auth-Attributes": "auth_attributes", "X-Apple-Session-Token": "session_token", "X-Apple-TwoSV-Trust-Token": "trust_token", "X-Apple-TwoSV-Trust-Eligible": "trust_eligible", "X-Apple-OAuth-Grant-Code": "grant_code", "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" class AppleAuthError(IntEnum): """Apple auth error codes.""" SUCCESS = 200 LOGIN_TOKEN_EXPIRED = 421 TWO_FACTOR_REQUIRED = 409 FIND_MY_REAUTH_REQUIRED = 450 GENERAL_AUTH_ERROR = 500 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/cookie_jar.py0000644000175100017510000000440415133166711017327 0ustar00runnerrunner"""Cookie jar with persistence support.""" from http.cookiejar import LWPCookieJar from typing import Optional from requests.cookies import RequestsCookieJar _FMIP_AUTH_COOKIE_NAME: str = "X-APPLE-WEBAUTH-FMIP" class PyiCloudCookieJar(RequestsCookieJar, LWPCookieJar): """Mix the Requests CookieJar with the LWPCookieJar to allow persistance""" def __init__(self, filename: Optional[str] = None) -> None: """Initialise both bases; do not pass filename positionally to RequestsCookieJar.""" RequestsCookieJar.__init__(self) LWPCookieJar.__init__(self, filename=filename) def _resolve_filename(self, filename: Optional[str]) -> Optional[str]: resolved: Optional[str] = filename or getattr(self, "filename", None) if not resolved: return # No-op if no filename is bound return resolved def load( self, filename: Optional[str] = None, ignore_discard: bool = True, ignore_expires: bool = False, ) -> None: """Load cookies from file.""" resolved: Optional[str] = self._resolve_filename(filename) if not resolved: return # No-op if no filename is bound super().load( filename=resolved, ignore_discard=ignore_discard, ignore_expires=ignore_expires, ) # Clear any FMIP cookie regardless of domain/path to avoid stale auth. cookies_to_clear: list[tuple[str, str, str]] = [ (cookie.domain, cookie.path, cookie.name) for cookie in self if cookie.name == _FMIP_AUTH_COOKIE_NAME ] for domain, path, name in cookies_to_clear: try: self.clear(domain=domain, path=path, name=name) except KeyError: pass def save( self, filename: Optional[str] = None, ignore_discard: bool = True, ignore_expires: bool = False, ) -> None: """Save cookies to file.""" resolved: Optional[str] = self._resolve_filename(filename) if not resolved: return # No-op if no filename is bound super().save( filename=resolved, ignore_discard=ignore_discard, ignore_expires=ignore_expires, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/exceptions.py0000644000175100017510000000573515133166711017413 0ustar00runnerrunner"""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, response: Optional[Response] = None, ) -> None: self.reason: str = reason self.code: Optional[Union[int, str]] = code self.response: Optional[Response] = response message: str = reason or "" if code: message += f" ({code})" if response is not None and response.text: message += f": {response.text}" super().__init__(message) class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException): """iCloud service not activated exception.""" # Login class PyiCloudFailedLoginException(PyiCloudException): """iCloud failed login exception.""" def __init__( self, msg: str, *args, response: Optional[Response] = None, ) -> None: self.response: Optional[Response] = response message: str = msg or "Failed login to iCloud" if response is not None and response.text: message = f"{message} ({response.status_code}): {response.text}" super().__init__(message, *args) class PyiCloudAcceptTermsException(PyiCloudException): """iCloud accept terms 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 PyiCloudAuthRequiredException(PyiCloudException): """iCloud re-authentication required exception.""" def __init__(self, apple_id: str, response: Response) -> None: message: str = f"Re-authentication required for account: {apple_id}" super().__init__(message) self.response: Response = response class PyiCloudNoTrustedNumberAvailable(PyiCloudException): """iCloud no trusted number exception.""" class PyiCloudNoStoredPasswordAvailableException(PyiCloudException): """iCloud no stored password exception.""" # Webservice specific class PyiCloudNoDevicesException(PyiCloudException): """iCloud no device exception.""" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9305506 pyicloud-2.3.0/pyicloud/services/0000755000175100017510000000000015133166716016476 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/services/__init__.py0000644000175100017510000000143515133166711020605 0ustar00runnerrunner"""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=1768746441.0 pyicloud-2.3.0/pyicloud/services/account.py0000644000175100017510000002576315133166711020514 0ustar00runnerrunner"""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 ( f"{{devices: {len(self.devices)}, family: {len(self.family)}, " f"storage: {self.storage.usage.available_storage_in_bytes} bytes free}}" ) 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 ( f"{{name: {self.full_name}, age_classification: {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 f"{self.used_storage_in_percent}% used of {self.total_storage_in_bytes} 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=1768746441.0 pyicloud-2.3.0/pyicloud/services/base.py0000644000175100017510000000143415133166711017757 0ustar00runnerrunner"""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=1768746441.0 pyicloud-2.3.0/pyicloud/services/calendar.py0000644000175100017510000006202615133166711020622 0ustar00runnerrunner"""Calendar service.""" import time from calendar import monthrange from dataclasses import asdict, dataclass, field, fields 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 from pyicloud.utils import camelcase_to_underscore T = TypeVar("T") # Calendar service constants class DateFormats: """Date format constants.""" API_DATE = "%Y-%m-%d" APPLE_DATE = "%Y%m%d" class CalendarDefaults: """Default values for calendars.""" TITLE = "Untitled" SYMBOLIC_COLOR = "__custom__" SUPPORTED_TYPE = "Event" OBJECT_TYPE = "personal" ORDER = 7 SHARE_TITLE = "" SHARED_URL = "" COLOR = "" class InviteeDefaults: """Default values for event invitees.""" ROLE = "REQ-PARTICIPANT" STATUS = "NEEDS-ACTION" class AlarmDefaults: """Default values for event alarms.""" MESSAGE_TYPE = "message" IS_LOCATION_BASED = False @dataclass class AlarmMeasurement: """ Represents the timing measurement for an alarm. """ before: bool = True weeks: int = 0 days: int = 0 hours: int = 0 minutes: int = 0 seconds: int = 0 @dataclass class AppleAlarm: """ Represents an alarm in Apple's Calendar API format. This is used in the main payload's Alarm array. """ # pylint: disable=invalid-name guid: str pGuid: str messageType: str = AlarmDefaults.MESSAGE_TYPE isLocationBased: bool = AlarmDefaults.IS_LOCATION_BASED measurement: AlarmMeasurement = field(default_factory=AlarmMeasurement) @dataclass class AppleDateFormat: """ Apple's 7-element date array format. Format: [YYYYMMDD, YYYY, MM, DD, HH, MM, minutes_from_midnight] The date_string uses YYYYMMDD format. """ date_string: str year: int month: int day: int hour: int minute: int minutes_from_midnight: int @classmethod def from_datetime(cls, dt: datetime, is_start: bool = True) -> "AppleDateFormat": """Convert Python datetime to Apple's date format.""" if is_start: minutes_calc = dt.hour * 60 + dt.minute else: minutes_calc = (24 - dt.hour) * 60 + (60 - dt.minute) return cls( date_string=dt.strftime(DateFormats.APPLE_DATE), year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute, minutes_from_midnight=minutes_calc, ) def to_list(self) -> list[int]: """Convert to Apple's expected list format.""" return [ self.date_string, self.year, self.month, self.day, self.hour, self.minute, self.minutes_from_midnight, ] @dataclass class AppleEventInvitee: """ Represents an invitee within the Event object in Apple's Calendar API. This is the simpler invitee structure used inside the Event.invitees array. """ # pylint: disable=invalid-name email: str role: str = InviteeDefaults.ROLE inviteeStatus: str = InviteeDefaults.STATUS @dataclass class ApplePayloadInvitee: """ Represents an invitee in the main payload's Invitee array in Apple's Calendar API. This is the more detailed invitee structure used in the top-level Invitee array. """ # pylint: disable=invalid-name guid: str pGuid: str role: str = InviteeDefaults.ROLE isOrganizer: bool = False email: str = "" inviteeStatus: str = InviteeDefaults.STATUS commonName: str = "" isMe: bool = False @dataclass class AppleCalendarEvent: """ Represents an event in Apple's Calendar API format. This dataclass exactly mirrors the structure expected by Apple's API, using the correct camelCase field names and types. """ # pylint: disable=invalid-name title: str tz: str icon: int duration: int allDay: bool pGuid: str guid: str startDate: List[int] endDate: List[int] localStartDate: List[int] localEndDate: List[int] createdDate: List[int] lastModifiedDate: List[int] extendedDetailsAreIncluded: bool recurrenceException: bool recurrenceMaster: bool hasAttachments: bool readOnly: bool = False transparent: bool = False birthdayIsYearlessBday: bool = False birthdayShowAsCompany: bool = False shouldShowJunkUIWhenAppropriate: bool = False location: str = "" url: str = "" description: str = "" etag: str = "" alarms: List[str] = field(default_factory=list) attachments: List[Any] = field(default_factory=list) invitees: List[str] = field(default_factory=list) changeRecurring: Optional[str] = None @dataclass class EventObject: """ An EventObject represents an event in the Apple Calendar. """ pguid: str title: str = "New Event" start_date: datetime = field(default_factory=datetime.today) end_date: datetime = field( default_factory=lambda: 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 = "" guid: str = "" 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) alarms: List[str] = field(init=False, default_factory=list) _alarm_metadata: dict[str, AlarmMeasurement] = field( init=False, default_factory=dict ) def __post_init__(self) -> None: # Validation: check required fields and data integrity if not self.pguid.strip(): raise ValueError("pguid cannot be empty") if self.start_date >= self.end_date: raise ValueError( f"start_date ({self.start_date}) must be before end_date ({self.end_date})" ) # Initialize optional dates 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 # Generate GUID if not provided if not self.guid: self.guid = str(uuid4()).upper() # Set timezone if not provided if not self.tz: self.tz = get_localzone_name() # Calculate duration (should now always be positive due to validation) self.duration = int( (self.end_date.timestamp() - self.start_date.timestamp()) / 60 ) def to_apple_event(self) -> AppleCalendarEvent: """ Convert this EventObject to Apple's API format. This method handles the conversion from our internal representation to the exact structure expected by Apple's Calendar API. """ # Convert Python datetime to Apple's date format start_date_list = self.dt_to_list(self.start_date) end_date_list = self.dt_to_list(self.end_date, False) local_start_list = ( self.dt_to_list(self.local_start_date) if self.local_start_date else start_date_list ) local_end_list = ( self.dt_to_list(self.local_end_date, False) if self.local_end_date else end_date_list ) current_timestamp = time.time() current_dt = datetime.fromtimestamp(current_timestamp) created_date_list = self.dt_to_list(current_dt) last_modified_list = self.dt_to_list(current_dt) invitees_list: List[str] = [] if self.invitees: invitees_list = self.invitees alarms_list: List[str] = [] if self.alarms: alarms_list = self.alarms return AppleCalendarEvent( title=self.title, tz=self.tz, icon=self.icon, duration=self.duration, allDay=self.all_day, pGuid=self.pguid, guid=self.guid, startDate=start_date_list, endDate=end_date_list, localStartDate=local_start_list, localEndDate=local_end_list, createdDate=created_date_list, lastModifiedDate=last_modified_list, extendedDetailsAreIncluded=self.extended_details_are_included, recurrenceException=self.recurrence_exception, recurrenceMaster=self.recurrence_master, hasAttachments=self.has_attachments, shouldShowJunkUIWhenAppropriate=self.is_junk, location=self.location, etag=self.etag or "", alarms=alarms_list, invitees=invitees_list, changeRecurring=self.change_recurring, ) @property def request_data(self) -> dict[str, Any]: """Returns the event data in the format required by Apple's calendar.""" apple_event = self.to_apple_event() event_dict = asdict(apple_event) data: dict[str, Any] = { "Event": event_dict, "Invitee": [], "Alarm": [], "ClientState": { "Collection": [{"guid": self.pguid, "ctag": None}], }, } if self.invitees: payload_invitees = [ ApplePayloadInvitee( guid=email_guid, pGuid=self.guid, role=InviteeDefaults.ROLE, isOrganizer=False, email=email_guid.split(":")[-1], inviteeStatus=InviteeDefaults.STATUS, commonName="", isMe=False, ) for email_guid in self.invitees ] data["Invitee"] = [asdict(invitee) for invitee in payload_invitees] if self.alarms: payload_alarms = [ AppleAlarm( guid=alarm_guid, pGuid=self.guid, messageType=AlarmDefaults.MESSAGE_TYPE, isLocationBased=AlarmDefaults.IS_LOCATION_BASED, measurement=self._alarm_metadata.get( alarm_guid, AlarmMeasurement() ), ) for alarm_guid in self.alarms ] data["Alarm"] = [asdict(alarm) for alarm in payload_alarms] 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. """ apple_date = AppleDateFormat.from_datetime(dt, is_start=start) return apple_date.to_list() def add_invitees(self, _invitees: Optional[list] = None) -> None: """ Adds a list of emails to invitees in the correct format """ if _invitees: self.invitees += [f"{self.guid}:{email}" for email in _invitees] def add_alarm_at_time(self) -> str: """ Adds an alarm at the time of the event. Returns the alarm GUID for reference. """ alarm_guid = str(uuid4()) alarm_full_guid = f"{self.guid}:{alarm_guid}" self.alarms.append(alarm_full_guid) self._alarm_metadata[alarm_full_guid] = AlarmMeasurement( before=False, weeks=0, days=0, hours=0, minutes=0, seconds=0 ) return alarm_guid def add_alarm_before( self, minutes: int = 0, hours: int = 0, days: int = 0, weeks: int = 0 ) -> str: """ Adds an alarm before the event. Args: minutes: Minutes before event hours: Hours before event days: Days before event weeks: Weeks before event Returns: The alarm GUID for reference. """ alarm_guid = str(uuid4()) alarm_full_guid = f"{self.guid}:{alarm_guid}" self.alarms.append(alarm_full_guid) self._alarm_metadata[alarm_full_guid] = AlarmMeasurement( before=True, weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=0 ) return alarm_guid 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 = CalendarDefaults.TITLE guid: str = "" share_type: Optional[str] = None symbolic_color: str = CalendarDefaults.SYMBOLIC_COLOR supported_type: str = CalendarDefaults.SUPPORTED_TYPE object_type: str = CalendarDefaults.OBJECT_TYPE share_title: str = CalendarDefaults.SHARE_TITLE shared_url: str = CalendarDefaults.SHARED_URL color: str = CalendarDefaults.COLOR order: int = CalendarDefaults.ORDER 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 f"#{randint(0, 255):02x}{randint(0, 255):02x}{randint(0, 255):02x}" @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, }, } 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() _, days_in_month = monthrange(today.year, today.month) # monthrange returns: weekday of the first day of the month (0 -> Mon, 6 -> Sun) and # number of days in the month (Jan -> 31, Feb -> 28/29, etc.) from_dt = datetime( today.year, today.month, 1 ) # Hardcoded to 1 so that startDate is always the first (1st) day of the month to_dt = datetime(today.year, today.month, days_in_month) params = dict(self.params) params.update( { "lang": "en-us", "usertz": get_localzone_name(), "startDate": from_dt.strftime(DateFormats.API_DATE), "endDate": to_dt.strftime(DateFormats.API_DATE), } ) return params def obj_from_dict(self, obj: T, _dict) -> T: """Creates an object from a dictionary with proper field validation.""" if hasattr(obj, "__dataclass_fields__"): valid_fields = {f.name for f in fields(obj)} special_mappings = { "pGuid": "pguid", "shouldShowJunkUIWhenAppropriate": "is_junk", } for api_key, value in _dict.items(): field_name: str = special_mappings.get( api_key, camelcase_to_underscore(api_key) ) if field_name in valid_fields: setattr(obj, field_name, value) else: 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]: """ Refresh the Calendar service and return a fresh event payload. Date range semantics: - If both 'from_dt' and 'to_dt' are provided, they are respected as-is. - If exactly one bound is provided, the missing bound is anchored to the same month as the provided bound and expanded to the full month (1st..last day of that month). - If neither is provided, defaults to the current month. Notes: - Apple's Calendar API treats 'endDate' as inclusive; events occurring on the last day of the month (including all-day and 23:00->00:00 boundary events) are returned. See tests in tests/test_calendar.py. """ today: datetime = datetime.today() # Anchor missing bound(s) to whichever bound is provided, else to 'today' anchor: datetime = from_dt or to_dt or today year, month = anchor.year, anchor.month _, days_in_month = monthrange( year, month ) # (weekday_of_first_day, days_in_month) # If either bound is missing, normalize to the full month of the anchor. # When both bounds are provided (e.g., day/week queries), respect them as-is. if from_dt is None or to_dt is None: from_dt = anchor.replace(day=1) to_dt = anchor.replace(day=days_in_month) params = dict(self.params) params.update( { "lang": "en-us", "usertz": get_localzone_name(), "startDate": from_dt.strftime(DateFormats.API_DATE), "endDate": to_dt.strftime(DateFormats.API_DATE), "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( f"{self._calendar_collections_url}/{calendar.guid}", params=params, json=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( f"{self._calendar_collections_url}/{cal_guid}", params=params, json={} ) return req.json() @overload def get_events( self, from_dt: Optional[datetime] = None, to_dt: Optional[datetime] = None, period: str = "month", ) -> list[dict[str, Any]]: ... @overload def get_events( self, from_dt: Optional[datetime] = None, to_dt: Optional[datetime] = None, period: str = "month", as_objs: Literal[False] = False, ) -> list[dict[str, Any]]: ... @overload def get_events( self, from_dt: Optional[datetime] = None, to_dt: Optional[datetime] = None, period: str = "month", as_objs: Literal[True] = True, ) -> list[EventObject]: ... def get_events( self, from_dt: Optional[datetime] = None, to_dt: Optional[datetime] = None, period: str = "month", as_objs: bool = False, ) -> Union[list[dict[str, Any]], list[EventObject]]: """ 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): pguid = event.get("pGuid", "") if not pguid: raise ValueError(f"Event missing required pGuid field: {event}") events[idx] = self.obj_from_dict(EventObject(pguid), 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.pguid) params = self.default_params req: Response = self.session.post( f"{self._calendar_refresh_url}/{event.pguid}/{event.guid}", params=params, json=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.pguid) 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( f"{self._calendar_refresh_url}/{event.pguid}/{event.guid}", params=params, json=data, ) return req.json() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/services/contacts.py0000644000175100017510000000715115133166711020665 0ustar00runnerrunner"""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[dict[str, Any]]] = None def refresh_client(self) -> None: """ Refreshes the ContactsService endpoint, ensuring that the contacts data is up-to-date. """ params_contacts: dict[str, Any] = 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[str, Any] = 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) -> list[dict[str, Any]] | None: """ 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=1768746441.0 pyicloud-2.3.0/pyicloud/services/drive.py0000644000175100017510000004547415133166711020172 0ustar00runnerrunner"""Drive service.""" import io 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(f"Can't extract token from {cookie.value}") return {"token": match.group(1)} raise TokenException("Token cookie not found") def get_node_data( self, drivewsid: str, share_id: Optional[dict[str, Any]] = None ) -> dict[str, Any]: """Returns the node data.""" payload = [ { "drivewsid": drivewsid, "partialData": False, } ] if share_id: payload[0]["shareID"] = share_id request: Response = self.session.post( self.service_root + "/retrieveItemDetailsInFolders", params=self.params, json=payload, ) 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[str, Any] = 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}, json={ "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}, json=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}, json={ "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, json={ "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, json={ "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, json={ "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, json={ "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, json={ "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: DriveService, data: dict[str, Any]) -> None: self.data: dict[str, Any] = data self.connection: DriveService = 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"], self.data.get("shareID") ) ) if "items" not in self.data: raise KeyError(f"No items in folder, status: {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"] ) 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"] ) 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=1768746441.0 pyicloud-2.3.0/pyicloud/services/findmyiphone.py0000644000175100017510000004132615133166711021542 0ustar00runnerrunner"""Find my iPhone service.""" import logging import threading import time from datetime import datetime, timedelta from types import MappingProxyType from typing import Any, Callable, Iterator, Optional from requests import Response from pyicloud.exceptions import ( PyiCloudAuthRequiredException, PyiCloudNoDevicesException, PyiCloudServiceUnavailable, ) from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession _FMIP_CLIENT_CONTEXT_TIMEZONE: str = "US/Pacific" _LOGGER: logging.Logger = logging.getLogger(__name__) _MAX_REFRESH_RETRIES: int = 5 def _monitor_thread( interval: float, func: Callable, stop_event: threading.Event, locate: bool = True ) -> None: next_event: datetime = datetime.now() + timedelta(seconds=interval) while not stop_event.wait(timeout=interval): if next_event < datetime.now(): try: func(locate) except Exception as exc: _LOGGER.debug("FindMyiPhone monitor thread error: %s", exc) next_event = datetime.now() + timedelta(seconds=interval) 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, token_endpoint: str, session: PyiCloudSession, params: dict[str, Any], with_family=False, ) -> None: """Initialize the FindMyiPhoneServiceManager.""" super().__init__(service_root, session, params) self._with_family: bool = with_family fmip_endpoint: str = f"{service_root}/fmipservice/client/web" self._fmip_init_url: str = f"{fmip_endpoint}/initClient" 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._fmip_erase_url: str = f"{fmip_endpoint}/remoteWipeWithUserAuth" self._erase_token_url: str = f"{token_endpoint}/fmipWebAuthenticate" self._devices: dict[str, AppleDevice] = {} self._devices_names: list[str] = [] self._server_ctx: dict[str, Any] | None = None self._user_info: dict[str, Any] | None = None self._monitor: Optional[threading.Thread] = None self.stop_event: threading.Event = threading.Event() self._refresh_client_with_reauth(locate=True) def _refresh_client_with_reauth( self, retry: bool = False, locate: bool = True ) -> None: """ Refreshes the FindMyiPhoneService endpoint with re-authentication. This ensures that the location data is up-to-date. """ # Refresh the client (own devices first) self.stop_event.set() try: self._refresh_client(locate=locate) except PyiCloudAuthRequiredException: if retry is True: raise _LOGGER.debug("Re-authenticating session") self._server_ctx = None self.session.service.authenticate(force_refresh=True) self._refresh_client_with_reauth(retry=True, locate=locate) return # Initialize devices (including family devices if enabled) self._initialize_devices(locate=locate) self._start_monitor_thread() def _initialize_devices(self, locate: bool = False) -> None: """Initializes the devices for the FindMyiPhoneServiceManager.""" # If family sharing is enabled, we may need to poll until all devices are ready # This is indicated by the deviceFetchStatus being "LOADING" retries: int = 0 while ( self._with_family and self._user_info and self._user_info.get("hasMembers", False) ): needs_refresh: bool = False for user in self._user_info.get("membersInfo", {}).values(): if user.get("deviceFetchStatus") == "LOADING": needs_refresh = True break if needs_refresh: time.sleep(0.1) self._refresh_client(locate=locate) retries += 1 if retries >= _MAX_REFRESH_RETRIES: _LOGGER.debug("Max retries reached when fetching family devices") break else: break if not self._devices: raise PyiCloudNoDevicesException() _LOGGER.info("Number of devices found: %d", len(self._devices)) def _start_monitor_thread(self) -> None: """Starts the monitor thread for the FindMyiPhoneServiceManager.""" if not self.is_alive: self._monitor = threading.Thread( target=_monitor_thread, kwargs={ "func": self._refresh_client, "interval": 1.0, "stop_event": self.stop_event, }, daemon=True, ) self.stop_event.clear() self._monitor.start() def _refresh_client(self, locate: bool = False) -> None: """ Refreshes the FindMyiPhoneService endpoint, this ensures that the location data is up-to-date. """ req_json: dict[str, Any] = { "clientContext": { "appName": "iCloud Find (Web)", "appVersion": "2.0", "apiVersion": "3.0", "deviceListVersion": 1, "fmly": self._with_family, "timezone": _FMIP_CLIENT_CONTEXT_TIMEZONE, "inactiveTime": 0, }, } if self._server_ctx: req_json["serverContext"] = self._server_ctx if locate: req_json["isUpdatingAllLocations"] = True req_json["clientContext"].update( { "shouldLocate": True, "selectedDevice": "all", } ) req: Response = self.session.post( url=self._fmip_refresh_url if self._server_ctx else self._fmip_init_url, params=self.params, json=req_json, ) resp: dict[str, Any] = req.json() self._server_ctx = resp.get("serverContext") if self._server_ctx and "theftLoss" in self._server_ctx: self._server_ctx["theftLoss"] = None self._user_info = resp.get("userInfo") if "content" not in resp: _LOGGER.debug("FMIP returned 0 devices") return _LOGGER.debug("FMIP returned %d devices", len(resp["content"])) for device_info in resp["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, erase_url=self._fmip_erase_url, erase_token_url=self._erase_token_url, ) else: self._devices[device_id].update(device_info) self._devices_names = list(self._devices.keys()) def refresh(self, locate: bool = False) -> None: """Public method to refresh the FindMyiPhoneService endpoint.""" self._refresh_client_with_reauth(locate=locate) def __getitem__(self, key: str | int) -> "AppleDevice": """Gets a device by name or index.""" if not self.is_alive: self._refresh_client_with_reauth(locate=True) if isinstance(key, int): key = self._devices_names[key] return self._devices[key] def __str__(self) -> str: """String representation of the devices.""" return f"{self._devices}" def __repr__(self) -> str: """Representation of the device.""" return f"{self}" def __iter__(self) -> Iterator["AppleDevice"]: """Iterates over the devices.""" if not self.is_alive: self._refresh_client_with_reauth(locate=True) return iter(self._devices.values()) def __len__(self) -> int: """Returns the number of devices.""" if not self.is_alive: self._refresh_client_with_reauth(locate=True) return len(self._devices) @property def is_alive(self) -> bool: """Indicates if the service is alive.""" return self._monitor is not None and self._monitor.is_alive() @property def devices(self) -> "MappingProxyType[str, AppleDevice]": """Returns the devices.""" return MappingProxyType(self._devices) @property def user_info(self) -> Optional[MappingProxyType[str, Any]]: """Returns the user info.""" return MappingProxyType(self._user_info) if self._user_info else None class AppleDevice: """Apple device.""" def __init__( self, content: dict[str, Any], params: dict[str, Any], manager: FindMyiPhoneServiceManager, sound_url: str, lost_url: str, erase_url: str, erase_token_url: str, message_url: str, ) -> None: """Initialize the Apple device.""" 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._erase_url: str = erase_url self._erase_token_url: str = erase_token_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) -> Optional[dict[str, Any]]: """Updates the device location.""" if self.location_available is False: return None 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. """ 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`. """ if self.sound_available is False: raise PyiCloudServiceUnavailable("Sound is not available for this device") data: dict[str, Any] = { "device": self._content["id"], "subject": subject, "clientContext": {"fmly": True}, } self.session.post(self._sound_url, params=self._params, json=data) def display_message( self, subject="Find My iPhone Alert", message="This is a note", sounds=False, vibrate=False, strobe=False, ) -> None: """Send a request to the device to a display a message. It's possible to pass a custom message by changing the `subject`. """ if self.messaging_available is False: raise PyiCloudServiceUnavailable( "Messaging is not available for this device" ) data: dict[str, Any] = { "device": self._content["id"], "subject": subject, "userText": True, "text": message, "sound": sounds, "vibrate": vibrate, "strobe": strobe, } self.session.post(self._message_url, params=self._params, json=data) def lost_device( self, number: str, text: str = "This device has been lost. Please call me.", newpasscode: str = "", ) -> 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. """ if self.lost_mode_available is False: raise PyiCloudServiceUnavailable( "Lost mode is not available for this device" ) data: dict[str, Any] = { "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, json=data) def _get_erase_token(self) -> str: """Get the erase token for the Find My iPhone service.""" data: dict[str, Any] = { "dsWebAuthToken": self.session.data.get("session_token"), } data = self.session.post(url=self._erase_token_url, json=data).json() if "tokens" not in data or "mmeFMIPWebEraseDeviceToken" not in data["tokens"]: raise PyiCloudServiceUnavailable("Find My iPhone erase token not available") return data["tokens"]["mmeFMIPWebEraseDeviceToken"] def erase_device( self, text: str = "This device has been lost. Please call me.", newpasscode: str = "", ) -> None: """Send a request to the device to start a remote erase.""" if self.erase_available is False: raise PyiCloudServiceUnavailable("Erase is not available for this device") data: dict[str, Any] = { "authToken": self._get_erase_token(), "text": text, "device": self._content["id"], "passcode": newpasscode, } self.session.post(self._erase_url, params=self._params, json=data) @property def data(self) -> dict[str, Any]: """Gets the device data.""" if not self._manager.is_alive: self._manager.refresh() return self._content def __getitem__(self, key) -> Any: """Gets an attribute of the device data.""" if not self._manager.is_alive: self._manager.refresh() return self._content[key] def __getattr__(self, attr) -> Any: """Gets an attribute of the device data.""" if not self._manager.is_alive: self._manager.refresh() if attr in self._content: return self._content[attr] raise AttributeError( f"'{type(self).__name__}' object has no attribute '{attr}'" ) @property def name(self) -> str: """Gets the device name.""" return self.data.get("name", "") @property def model(self) -> str: """Gets the device model.""" return self.data.get("deviceModel", "") @property def model_name(self) -> str: """Gets the device model name.""" return self.data.get("deviceDisplayName", "") @property def device_type(self) -> str: """Gets the device type.""" return self.data.get("deviceClass", "") @property def lost_mode_available(self) -> bool: """Indicates if lost mode is available for the device.""" return self.data.get("lostModeCapable", False) @property def messaging_available(self) -> bool: """Indicates if messaging is available for the device.""" return self.data.get("features", {}).get("MSG", False) @property def sound_available(self) -> bool: """Indicates if sound is available for the device.""" return self.data.get("features", {}).get("SND", False) @property def erase_available(self) -> bool: """Indicates if erase is available for the device.""" return self.data.get("features", {}).get("WIP", False) @property def location_available(self) -> bool: """Indicates if location is available for the device.""" return ( self.data.get("features", {}).get("LOC", False) and "location" in self._content and self._content["location"] is not None ) def __str__(self) -> str: """String representation of the device.""" return f"{self.model_name}: {self.name}" def __repr__(self) -> str: """Representation of the device.""" return f"" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/services/hidemyemail.py0000644000175100017510000001514715133166711021342 0ustar00runnerrunner"""Hide my email service.""" 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, json={ "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, json={"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, json=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, json={"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, json={"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, json={"anonymousId": anonymous_id}, ) response = req.json() return response.get("result", {}) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/services/photos.py0000644000175100017510000015616615133166711020376 0ustar00runnerrunner"""Photo service.""" import base64 import logging import os from abc import abstractmethod from datetime import datetime, timezone from enum import Enum, 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, PyiCloudException, PyiCloudServiceNotActivatedException, ) from pyicloud.services.base import BaseService from pyicloud.session import PyiCloudSession _LOGGER: logging.Logger = logging.getLogger(__name__) class PhotosServiceException(PyiCloudException): """Photo service exception.""" def __init__( self, *args, photo: "PhotoAsset|None" = None, album: "BasePhotoAlbum|None" = None, ) -> None: super().__init__(*args) self.photo: "PhotoAsset|None" = photo self.album: "BasePhotoAlbum|None" = album @unique class AlbumTypeEnum(IntEnum): """Album types""" ALBUM = 0 FOLDER = 3 SMART_ALBUM = 6 class SmartAlbumEnum(str, Enum): """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(str, Enum): """Direction names.""" ASCENDING = "ASCENDING" DESCENDING = "DESCENDING" class ListTypeEnum(str, Enum): """List type names.""" DEFAULT = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted" DELETED = "CPLAssetAndMasterDeletedByExpungedDate" HIDDEN = "CPLAssetAndMasterHiddenByAssetDate" SMART_ALBUM = "CPLAssetAndMasterInSmartAlbumByAssetDate" STACK = "CPLBurstStackAssetAndMasterByAssetDate" CONTAINER = "CPLContainerRelationLiveByAssetDate" SHARED_STREAM = "sharedstream" class ObjectTypeEnum(str, Enum): """Object type names.""" ALL = "CPLAssetByAssetDateWithoutHiddenOrDeleted" BURST = "CPLAssetBurstStackAssetByAssetDate" DELETED = "CPLAssetDeletedByExpungedDate" FAVORITE = "CPLAssetInSmartAlbumByAssetDate:Favorite" HIDDEN = "CPLAssetHiddenByAssetDate" LIVE = "CPLAssetInSmartAlbumByAssetDate:Live" PANORAMA = "CPLAssetInSmartAlbumByAssetDate:Panorama" SCREENSHOT = "CPLAssetInSmartAlbumByAssetDate:Screenshot" SLOMO = "CPLAssetInSmartAlbumByAssetDate:Slomo" TIMELAPSE = "CPLAssetInSmartAlbumByAssetDate:Timelapse" VIDEO = "CPLAssetInSmartAlbumByAssetDate:Video" CONTAINER = "CPLContainerRelationNotDeletedByAssetDate" # 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: list["BasePhotoAlbum"] | None = None) -> None: if albums is not None: self._albums: dict[str, "BasePhotoAlbum"] = { album.id: album for album in albums } else: self._albums = {} self._index: list[str] = list(self._albums.keys()) def __len__(self) -> int: return len(self._albums) def __getitem__(self, key: str | int) -> "BasePhotoAlbum": """Returns the album for the given id.""" if isinstance(key, int): return self._albums[self._index[key]] if key in self._albums: return self._albums[key] album: BasePhotoAlbum | None = self.find(key) if album is not None: return album raise KeyError(f"Photo album does not exist: {key}") def __iter__(self) -> Iterator["BasePhotoAlbum"]: return iter(self._albums.values()) def __contains__(self, name: str) -> bool: """Checks if an album exists in the container.""" return self.find(name) is not None def find(self, name: str) -> Optional["BasePhotoAlbum"]: """Finds an album by name, returns None if not found.""" for album in self._albums.values(): if name == album.fullname: return album return None def get( self, key: str, default: "BasePhotoAlbum | None" = None ) -> "BasePhotoAlbum | None": """Returns the album for the given key, or default if not found.""" return self._albums.get(key, default) def append(self, album: "BasePhotoAlbum") -> None: """Appends an album to the container.""" self._albums[album.id] = album self._index: list[str] = list(self._albums.keys()) def index(self, idx: int) -> "BasePhotoAlbum": """Returns the album at the given index.""" if idx < 0 or idx >= len(self._index): raise IndexError("Photo album index out of range") return self._albums[self._index[idx]] 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", asset_type: type["PhotoAsset"], upload_url: Optional[str] = None, ) -> None: self.service: PhotosService = service self.asset_type: type[PhotoAsset] = asset_type self._albums: Optional[AlbumContainer] = None self._upload_url: Optional[str] = upload_url @abstractmethod def _get_albums(self) -> AlbumContainer: """Returns the photo albums.""" raise NotImplementedError @property def albums(self) -> AlbumContainer: """Returns the photo albums.""" if self._albums is None: self._albums = self._get_albums() return self._albums def parse_asset_response( self, response: dict[str, list[dict[str, Any]]] ) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]: """Parses the asset response.""" 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) class PhotoLibrary(BasePhotoLibrary): """Represents the user's primary photo libraries.""" SMART_ALBUMS: dict[SmartAlbumEnum, 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.TIMELAPSE, "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, asset_type=PhotoAsset, upload_url=upload_url) self.zone_id: dict[str, str] = zone_id self.url: str = ( f"{self.service.service_endpoint}" f"/records/query?{urlencode(self.service.params)}" ) request: Response = self.service.session.post( url=self.url, json={ "query": { "recordType": "CheckIndexingState", }, "zoneID": self.zone_id, }, 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, json=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, json=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 _convert_record_to_album( self, record: dict[str, Any] ) -> Optional["PhotoAlbum"]: """Converts a record to a photo album.""" if ( # Skipping albums having null name, that can happen sometime "albumNameEnc" not in record["fields"] or ( record["fields"].get("isDeleted") and record["fields"]["isDeleted"]["value"] ) ): return None record_id: str = record["recordName"] 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") album_type: type[PhotoAlbum] = PhotoAlbum if ( record["fields"].get("albumType") and record["fields"]["albumType"]["value"] == AlbumTypeEnum.FOLDER.value ): album_type = PhotoAlbumFolder direction: DirectionEnum = DirectionEnum.ASCENDING if record["fields"].get("sortAscending", {}).get("value", 1) != 1: direction = DirectionEnum.DESCENDING record_modification_date = ( record["fields"].get("recordModificationDate", {}).get("value", None) ) return album_type( library=self, name=album_name, record_id=record_id, list_type=ListTypeEnum.CONTAINER, obj_type=ObjectTypeEnum.CONTAINER, direction=direction, url=self.url, query_filter=query_filter, zone_id=record.get("zoneID", self.zone_id), parent_id=parent_id, record_change_tag=record["recordChangeTag"], record_modification_date=record_modification_date, ) def _get_albums(self) -> AlbumContainer: """Returns photo albums.""" albums = AlbumContainer( [ SmartPhotoAlbum( 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(): album: PhotoAlbum | None = self._convert_record_to_album(record) if album is not None: albums.append(album) return albums def create_album( self, name: str, album_type: AlbumTypeEnum = AlbumTypeEnum.ALBUM ) -> Optional["PhotoAlbum"]: """Creates a new album, returns the request response.""" data: dict[str, Any] = { "operations": [ { "operationType": "create", "record": { "recordType": "CPLAlbum", "fields": { "albumNameEnc": { "value": base64.b64encode(name.encode("utf-8")).decode( "utf-8" ), }, "albumType": { "value": album_type.value, }, "isDeleted": { "value": 0, }, "isExpunged": { "value": 0, }, "sortType": { "value": 1, }, "sortAscending": { "value": 1, }, }, }, } ], "zoneID": self.zone_id, "atomic": True, } endpoint: str = self.service.service_endpoint params: str = urlencode(self.service.params) url: str = f"{endpoint}/records/modify?{params}" try: resp: Response = self.service.session.post( url, json=data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) payload: dict[str, Any] = resp.json() records: list[dict[str, Any]] = payload.get("records", []) if not records: return None except PyiCloudAPIResponseException as ex: _LOGGER.error("Failed to create album: %s", ex) raise PhotosServiceException("Failed to create album") from ex return self._convert_record_to_album(records[0]) def upload_file(self, path: str) -> Optional["PhotoAsset"]: """Upload a photo from path, returns a recordName""" filename: str = os.path.basename(path) params: dict[str, Any] = self.service.params.copy() params["filename"] = filename url: str = f"{self._upload_url}/upload?{urlencode(params)}" with open(path, "rb") as file_obj: response: Response = self.service.session.post( url=url, data=file_obj, ) json_response: dict[str, Any] = response.json() if "errors" in json_response: raise PyiCloudAPIResponseException("", json_response["errors"]) records: dict[Any, dict[str, Any]] = { rec["recordType"]: rec for rec in json_response["records"] } if "CPLMaster" not in records or "CPLAsset" not in records: return None return self.asset_type(self.service, records["CPLMaster"], records["CPLAsset"]) @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, asset_type=PhotoStreamAsset, upload_url=None) self.shared_streams_url: str = shared_streams_url def _get_albums(self) -> AlbumContainer: """Returns albums.""" albums: AlbumContainer = AlbumContainer() url: str = f"{self.shared_streams_url}?{urlencode(self.service.params)}" request: Response = self.service.session.post( url, json={}, 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.append(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 def create_album( self, name: str, album_type: AlbumTypeEnum = AlbumTypeEnum.ALBUM ) -> Optional["PhotoAlbum"]: """Creates a new album in the primary photo library.""" return self._root_library.create_album(name, album_type) class BasePhotoAlbum: """An abstract photo album.""" def __init__( self, library: BasePhotoLibrary, name: str, list_type: ListTypeEnum, page_size: int = 100, direction: DirectionEnum = DirectionEnum.ASCENDING, ) -> None: self._name: str = name self._library: BasePhotoLibrary = library self._page_size: int = page_size self._direction: DirectionEnum = direction self._list_type: ListTypeEnum = list_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 page_size(self) -> int: """Gets the page size.""" return self._page_size if self._page_size < 100 else 100 @property def service(self) -> PhotosService: """Get the Photo service""" return self._library.service def _get_photos_at( self, index: int, direction: DirectionEnum, page_size: int ) -> Generator["PhotoAsset", None, None]: offset: int = max(0, index) response: Response = self.service.session.post( url=self._get_url(), json=self._get_payload( offset=offset, page_size=page_size * 2, # Fetch double the page size to cater for master and asset records direction=direction, ), headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) json_response: dict[str, list[dict[str, Any]]] = response.json() asset_records: dict[str, Any] master_records: list[dict[str, Any]] asset_records, master_records = self._library.parse_asset_response( json_response ) for master_record in master_records: record_name: str = master_record["recordName"] asset_record = asset_records.get(record_name) if not asset_record: _LOGGER.debug( "No asset record found for master record: %s", record_name ) continue yield self._library.asset_type(self.service, master_record, asset_record) def photo(self, index) -> "PhotoAsset": """Returns a photo at the given index.""" return next(self._get_photos_at(index, self._direction, 1)) @property def title(self) -> str: """Gets the album title.""" return self.name @property def name(self) -> str: """Gets the album name.""" return self._name @name.setter def name(self, value: str) -> None: """Sets the album name.""" if self._name != value: self.rename(value) def rename(self, value: str) -> None: """Renames the album.""" raise NotImplementedError("Album name is read-only") def delete(self) -> bool: """Deletes the album.""" raise NotImplementedError("Album delete is not implemented") @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 photos_ids: set[str] = set() while True: num_results = 0 for photo in self._get_photos_at(offset, self._direction, self.page_size): num_results += 1 if photo.id in photos_ids: _LOGGER.debug("Duplicate photo found: %s, skipping", photo.id) continue photos_ids.add(photo.id) yield photo if num_results < self.page_size: _LOGGER.debug("Less than page size returned: %d", num_results) if ( num_results < self.page_size // 2 ): # If less than half the page size is returned, we assume we're done break if self._direction == DirectionEnum.DESCENDING: offset = offset - num_results else: offset = offset + num_results @property @abstractmethod def id(self) -> str: """Gets the album id.""" raise NotImplementedError @abstractmethod def _get_payload( self, offset: int, page_size: int, direction: DirectionEnum ) -> dict[str, Any]: """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: PhotoLibrary, name: str, record_id: str, obj_type: ObjectTypeEnum, list_type: ListTypeEnum, direction: DirectionEnum, 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, record_change_tag: Optional[str] = None, record_modification_date: Optional[str] = None, ) -> None: super().__init__( library=library, name=name, list_type=list_type, page_size=page_size, direction=direction, ) self._record_id: str = record_id self._obj_type: ObjectTypeEnum = obj_type self._query_filter: Optional[list[dict[str, Any]]] = query_filter self._url: str = url self._parent_id: Optional[str] = parent_id self._record_change_tag: Optional[str] = record_change_tag self._record_modification_date: Optional[str] = record_modification_date if zone_id: self._zone_id: dict[str, str] = zone_id else: self._zone_id = PRIMARY_ZONE @property def id(self) -> str: """Gets the album id.""" return self._record_id @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 rename(self, value: str) -> None: """Renames the album.""" if self._name == value: return data: dict[str, Any] = { "atomic": True, "zoneID": self._zone_id, "operations": [ { "operationType": "update", "record": { "recordName": self._record_id, "recordType": "CPLAlbum", "recordChangeTag": self._record_change_tag, "fields": { "albumNameEnc": { "value": base64.b64encode(value.encode("utf-8")).decode( "utf-8" ), }, }, }, } ], } url: str = ( f"{self.service.service_endpoint}/records/modify" f"?{urlencode(self.service.params)}" ) response: Response = self.service.session.post( url, json=data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) payload: dict[str, Any] = response.json() if payload.get("records"): latest: dict[str, Any] = payload["records"][0] self._record_change_tag = latest.get( "recordChangeTag", self._record_change_tag ) fields: dict[str, Any] = latest.get("fields", {}) self._record_modification_date = fields.get( "recordModificationDate", {} ).get("value", self._record_modification_date) self._name = value def delete(self) -> bool: """Deletes the album.""" data: dict[str, Any] = { "atomic": True, "zoneID": self._zone_id, "operations": [ { "operationType": "update", "record": { "recordName": self._record_id, "recordChangeTag": self._record_change_tag, "recordType": "CPLAlbum", "fields": { "isDeleted": {"value": 1}, }, }, } ], } url: str = ( f"{self.service.service_endpoint}/records/modify" f"?{urlencode(self.service.params)}" ) try: response: Response = self.service.session.post( url, json=data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) payload: dict[str, Any] = response.json() self._record_change_tag = payload["records"][0].get( "recordChangeTag", self._record_change_tag ) self._record_modification_date = ( payload["records"][0] .get("fields", {}) .get("recordModificationDate", {}) .get("value", self._record_modification_date) ) except PyiCloudAPIResponseException as ex: _LOGGER.error("Failed to delete photo from album: %s", ex) raise PhotosServiceException( "Failed to delete photo from album", album=self ) from ex return True def add_photo(self, photo: "PhotoAsset") -> bool: """Adds an existing photo to the album.""" data: dict[str, Any] = { "atomic": True, "zoneID": self._zone_id, "operations": [ { "operationType": "create", "record": { "fields": { "itemId": {"value": photo.id}, "position": {"value": 1024}, "containerId": {"value": self._record_id}, }, "recordType": "CPLContainerRelation", "recordName": f"{photo.id}-IN-{self._record_id}", }, } ], } url: str = ( f"{self.service.service_endpoint}/records/modify" f"?{urlencode(self.service.params)}" ) try: response: Response = self.service.session.post( url, json=data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) payload: dict[str, Any] = response.json() self._record_change_tag = payload["records"][0].get( "recordChangeTag", self._record_change_tag ) self._record_modification_date = ( payload["records"][0] .get("fields", {}) .get("recordModificationDate", {}) .get("value", self._record_modification_date) ) except PyiCloudAPIResponseException as ex: _LOGGER.error("Failed to add photo to album: %s", ex) return False return True def upload(self, path) -> Optional["PhotoAsset"]: """Uploads a photo to the album.""" if not isinstance(self._library, PhotoLibrary): return None photo_asset: PhotoAsset | None = self._library.upload_file(path) if photo_asset is None: return None if not self.add_photo(photo_asset): _LOGGER.error("Failed to add photo to album") raise PhotosServiceException( "Failed to add photo to album", album=self, photo=photo_asset, ) return photo_asset @property def _get_container_id(self) -> str: """Returns the container ID.""" return f"{self._obj_type.value}:{self._record_id}" def _get_len(self) -> int: url: str = ( f"{self.service.service_endpoint}/internal/records/query/batch" f"?{urlencode(self.service.params)}" ) request: Response = self.service.session.post( url, json={ "batch": [ { "resultsLimit": 1, "query": { "recordType": "HyperionIndexCountLookup", "filterBy": { "fieldName": "indexCountID", "comparator": "IN", "fieldValue": { "type": "STRING_LIST", "value": [self._get_container_id], }, }, }, "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: DirectionEnum ) -> dict[str, Any]: 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: ListTypeEnum, direction: DirectionEnum, num_results: int, query_filter=None, ) -> dict[str, Any]: query: dict[str, Any] = { "query": { "recordType": list_type.value, "filterBy": [ { "fieldName": "direction", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": direction.value}, }, { "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 PhotoAlbumFolder(PhotoAlbum): """A Photo Album Folder.""" def upload(self, path) -> Optional["PhotoAsset"]: """Uploads a photo to the album.""" # Folders do not support uploads return None class SmartPhotoAlbum(PhotoAlbum): """A Smart Photo Album.""" def __init__( self, library: PhotoLibrary, name: SmartAlbumEnum, obj_type: ObjectTypeEnum, list_type: ListTypeEnum, direction: DirectionEnum, 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.value, record_id=name.value, obj_type=obj_type, list_type=list_type, direction=direction, url=url, query_filter=query_filter, zone_id=zone_id, page_size=page_size, parent_id=parent_id, ) @property def id(self) -> str: """Gets the album id.""" return self.name def upload(self, path) -> Optional["PhotoAsset"]: """Uploads a photo to the album.""" # Smart albums do not support uploads return None @property def fullname(self) -> str: """Gets the full name of the album including path""" return self.name @property def _get_container_id(self) -> str: """Gets the container ID.""" return f"{self._obj_type.value}" 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=ListTypeEnum.SHARED_STREAM, page_size=page_size, ) 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 id(self) -> str: """Gets the album id.""" return self._album_guid @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: DirectionEnum ) -> dict[str, Any]: 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, json={ "albumguid": self._album_guid, }, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) response: dict[str, Any] = request.json() return response["albumassetcount"] def delete(self) -> bool: """Deletes the album.""" # Shared albums cannot be deleted return False def rename(self, value: str) -> None: """Renames the album.""" # Shared albums cannot be renamed return None 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", } FILE_TYPE_EXTENSIONS: dict[str, str] = { "public.heic": ".HEIC", "public.jpeg": ".JPG", "public.png": ".PNG", "com.apple.quicktime-movie": ".MOV", } PHOTO_VERSION_LOOKUP: dict[str, str] = { "original": "resOriginal", "medium": "resJPEGMed", "thumb": "resJPEGThumb", "original_video": "resOriginalVidCompl", "medium_video": "resVidMed", "thumb_video": "resVidSmall", } 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.""" item_type: str = "" try: item_type = self._master_record["fields"]["itemType"]["value"] except KeyError: try: item_type = self._master_record["fields"]["resOriginalFileType"][ "value" ] except KeyError: # Both fields missing; fall back to filename extension or default to "movie". pass 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 is_live_photo(self) -> bool: """Check if the photo is a live photo.""" return ( self.item_type == "image" and "resOriginalVidComplFileType" in self._master_record["fields"] ) @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[str, Any] = {} 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 # Default to the master filename. version["filename"] = self.filename # For live photos, the video version has a different filename. if self.is_live_photo: version_type: Optional[str] = version.get("type") # Check if the current version is the video component of the live photo. if version_type and self.ITEM_TYPES.get(version_type, None) == "movie": # Create the video filename from the image filename. # e.g. IMG_1234.HEIC -> IMG_1234.MOV filename_base, _ = os.path.splitext(self.filename) extension: str = self.FILE_TYPE_EXTENSIONS.get(version_type, ".MOV") live_photo_video_filename: str = f"{filename_base}{extension}" version["filename"] = live_photo_video_filename return version def download(self, version="original", **kwargs) -> Optional[bytes]: """Returns the photo file.""" if version not in self.versions: return None response: Response = self._service.session.get( self.versions[version]["url"], stream=True, **kwargs, ) return response.raw.read() def delete(self) -> bool: """Deletes the photo.""" endpoint: str = self._service.service_endpoint params: str = urlencode(self._service.params) url: str = f"{endpoint}/records/modify?{params}" resp: Response = self._service.session.post( url, json={ "operations": [ { "operationType": "update", "record": { "recordName": self._asset_record["recordName"], "recordType": self._asset_record["recordType"], "recordChangeTag": self._asset_record.get( "recordChangeTag", self._master_record.get("recordChangeTag"), ), "fields": {"isDeleted": {"value": 1}}, }, } ], "zoneID": self._asset_record["zoneID"], "atomic": True, }, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) return resp.status_code == 200 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=1768746441.0 pyicloud-2.3.0/pyicloud/services/reminders.py0000644000175100017510000000772115133166711021042 0ustar00runnerrunner"""Reminders service.""" 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", json={ "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=1768746441.0 pyicloud-2.3.0/pyicloud/services/ubiquity.py0000644000175100017510000001001215133166711020710 0ustar00runnerrunner"""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=1768746441.0 pyicloud-2.3.0/pyicloud/session.py0000644000175100017510000002410415133166711016704 0ustar00runnerrunner"""Pyicloud Session handling""" import logging import os from json import JSONDecodeError, dump, load from os import path from re import match from typing import TYPE_CHECKING, Any, NoReturn, Optional, Union, cast import requests 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, AppleAuthError, ) from pyicloud.cookie_jar import PyiCloudCookieJar from pyicloud.exceptions import ( PyiCloud2FARequiredException, PyiCloud2SARequiredException, PyiCloudAPIResponseException, PyiCloudAuthRequiredException, PyiCloudServiceNotActivatedException, ) if TYPE_CHECKING: from pyicloud.base import PyiCloudService class PyiCloudSession(requests.Session): """iCloud session.""" def __init__( self, service: "PyiCloudService", client_id: str, cookie_directory: str, verify: bool = False, headers: Optional[dict[str, str]] = None, ) -> None: super().__init__() self._service: PyiCloudService = service self.verify = verify self._cookie_directory: str = cookie_directory self.cookies = PyiCloudCookieJar(filename=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""" return self._logger def _load_session_data(self) -> None: """Load session_data from file.""" if os.path.exists(self.cookiejar_path): try: cast(PyiCloudCookieJar, self.cookies).load() except (OSError, ValueError) as exc: self._logger.warning( "Failed to load cookie jar %s: %s; starting without persisted cookies", self.cookiejar_path, exc, ) cast(PyiCloudCookieJar, self.cookies).clear() 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.""" if self._cookie_directory and not os.path.isdir(self._cookie_directory): os.makedirs(self._cookie_directory, exist_ok=True) 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) try: cast(PyiCloudCookieJar, self.cookies).save() self.logger.debug("Saved cookies data to file: %s", self.cookiejar_path) except (OSError, ValueError) as exc: self.logger.warning("Failed to save cookies data: %s", exc) 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 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, **kwargs, ) -> Response: """Request method.""" self.logger.debug( "%s %s", method, url, ) try: response: Response = super().request( method=method, url=url, **kwargs, ) self._update_session_data(response) self._save_session_data() status_code: int = int(response.status_code) if not response.ok and ( self._is_json_response(response) or status_code in [ AppleAuthError.TWO_FACTOR_REQUIRED, AppleAuthError.FIND_MY_REAUTH_REQUIRED, AppleAuthError.LOGIN_TOKEN_EXPIRED, AppleAuthError.GENERAL_AUTH_ERROR, ], ): return self._handle_request_error( status_code=status_code, response=response, ) 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, status_code: int, response: Response, ) -> Response: """Handle request error.""" if ( status_code == AppleAuthError.TWO_FACTOR_REQUIRED and self._is_json_response(response) and (response.json().get("authType") == "hsa2") ): raise PyiCloud2FARequiredException( apple_id=self.service.account_name, response=response, ) if status_code == AppleAuthError.FIND_MY_REAUTH_REQUIRED: raise PyiCloudAuthRequiredException( apple_id=self.service.account_name, response=response, ) 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(response, code, reason) except JSONDecodeError: self.logger.warning( "Failed to parse response with JSON mimetype: %s", response.text ) def _raise_error( self, response: Response, 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, response) 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 isinstance(code, int) and code in [ AppleAuthError.TWO_FACTOR_REQUIRED, AppleAuthError.FIND_MY_REAUTH_REQUIRED, AppleAuthError.LOGIN_TOKEN_EXPIRED, AppleAuthError.GENERAL_AUTH_ERROR, ]: reason = "Authentication required for Account." raise PyiCloudAPIResponseException(reason, code, response) @property def service(self) -> "PyiCloudService": """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=1768746441.0 pyicloud-2.3.0/pyicloud/srp_password.py0000644000175100017510000000304115133166711017744 0ustar00runnerrunner"""SRP password handling.""" from enum import Enum from hashlib import pbkdf2_hmac, sha256 class SrpProtocolType(Enum): """SRP password types.""" S2K = "s2k" S2K_FO = "s2k_fo" class SrpPassword: """SRP password.""" def __init__(self, password: str) -> None: self._password_hash: bytes = sha256(password.encode("utf-8")).digest() self.salt: bytes | None = None self.iterations: int | None = None self.key_length: int | None = None self.protocol: SrpProtocolType | None = None def set_encrypt_info( self, salt: bytes, iterations: int, key_length: int, protocol: SrpProtocolType ) -> None: """Set encrypt info.""" self.salt = salt self.iterations = iterations self.key_length = key_length self.protocol = protocol def encode(self) -> bytes: """Encode password.""" if self.salt is None or self.iterations is None or self.key_length is None: raise ValueError("Encrypt info not set") password_digest: bytes | None = None if self.protocol == SrpProtocolType.S2K_FO: password_digest = self._password_hash.hex().encode() elif self.protocol == SrpProtocolType.S2K: password_digest = self._password_hash if password_digest is None: raise ValueError("Unsupported SrpPassword type") return pbkdf2_hmac( "sha256", password_digest, self.salt, self.iterations, self.key_length, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/ssl_context.py0000644000175100017510000000413415133166711017567 0ustar00runnerrunner"""Context manager to configure SSL verification for requests""" import contextlib import logging import warnings from typing import Any, Callable, Generator import requests from urllib3.exceptions import InsecureRequestWarning logger: logging.Logger = logging.getLogger(__name__) @contextlib.contextmanager def configurable_ssl_verification( verify_ssl: bool = True, http_proxy: str | None = None, https_proxy: str | None = None, ) -> Generator[None, Any, None]: """Context manager to configure SSL verification for requests Warning: Setting verify_ssl=False disables certificate validation, making connections vulnerable to man-in-the-middle attacks. Only use in trusted environments for testing purposes. """ # Store the original merge_environment_settings old_merge_environment_settings: Callable = ( requests.Session.merge_environment_settings ) def merge_environment_settings_with_config( self, url, proxies, stream, verify, cert ): settings = old_merge_environment_settings( self, url, proxies, stream, verify, cert ) if not verify_ssl: settings["verify"] = False # Only set proxies if at least one is non-empty override_proxies: dict[str, str] = {} if http_proxy: override_proxies["http"] = http_proxy if https_proxy: override_proxies["https"] = https_proxy if override_proxies: settings["proxies"] = override_proxies return settings # Temporarily override merge_environment_settings requests.Session.merge_environment_settings = merge_environment_settings_with_config try: # Only catch InsecureRequestWarning if we are disabling SSL verification if not verify_ssl: with warnings.catch_warnings(): warnings.simplefilter("ignore", InsecureRequestWarning) yield else: yield finally: # Restore the original merge_environment_settings requests.Session.merge_environment_settings = old_merge_environment_settings ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyicloud/utils.py0000644000175100017510000000447615133166711016373 0ustar00runnerrunner"""Utils.""" import base64 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) def camelcase_to_underscore(camel_str: str) -> str: """ Convert camelCase string to snake_case. Examples: startDate -> start_date localStartDate -> local_start_date hasAttachments -> has_attachments """ result: list[str] = [] for i, char in enumerate(camel_str): if char.isupper() and i > 0: result.append("_") result.append(char.lower()) return "".join(result) 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 base64 encoded string.""" return base64.b64encode(b).decode() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9355505 pyicloud-2.3.0/pyicloud.egg-info/0000755000175100017510000000000015133166716016345 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/PKG-INFO0000644000175100017510000007225715133166715017456 0ustar00runnerrunnerMetadata-Version: 2.4 Name: pyicloud Version: 2.3.0 Summary: PyiCloud is a module which allows pythonistas to interact with iCloud webservices. Author: The PyiCloud Authors License-Expression: MIT 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.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.10 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 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 the iCloud web application using your username and password, then performs regular queries against its API. **Please see the [terms of use](TERMS_OF_USE.md) for your responsibilities when using this library.** For support and discussions, join our Discord community: [Join our Discord community](https://discord.gg/nru3was4hk) ## 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 Enter iCloud password for jappleseed@apple.com: Save password in keyring? [y/N]: N ``` **Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. **Note**: Apple will require you to accept new terms and conditions to access the iCloud web service. This will result in login failures until the terms are accepted. This can be automatically accepted by PyiCloud using the `--accept-terms` command-line option. Alternatively you can visit the iCloud web site to view and accept the terms. ### 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) ``` ### Erase Device Erase Device functionality, forces the device to be erased when next connected to a network. It allows the person who picks up the phone to see 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 message = 'Thief! Return my phone immediately.' api.iphone.erase_device(message) ``` ## Calendar The calendar webservice supports fetching, creating, and removing calendars and events, with support for alarms, and invitees. ### 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 from pyicloud import PyiCloudService from pyicloud.services.calendar import CalendarObject api = PyiCloudService("username", "password") calendar_service = api.calendar cal = 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 with support for alarms and invitees. `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 required parameter. The `EventObject` includes automatic validation, dynamic timezone detection, and multiple methods for event management. #### Key Features - **Automatic Validation**: Events validate required fields, date ranges, and calendar GUIDs - **Dynamic Timezone Detection**: Automatically detects and uses the user's local timezone - **Alarm Support**: Add alarms at event time or before the event with flexible timing - **Invitee Management**: Add multiple invitees who will receive email notifications #### 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 referencing `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 specific 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`.* #### EventObject Methods **add_invitees(emails: list) -> None**
*Adds a list of email addresses as invitees to the event. They will receive email notifications when the event is created.* **add_alarm_at_time() -> str**
*Adds an alarm that triggers at the exact time of the event. Returns the alarm GUID for reference.* **add_alarm_before(minutes=0, hours=0, days=0, weeks=0) -> str**
*Adds an alarm that triggers before the event starts. You can specify any combination of time units. Returns the alarm GUID for reference.* #### Examples *Create an event with invitees and alarms:* ``` python from datetime import datetime, timedelta from pyicloud import PyiCloudService from pyicloud.services.calendar import EventObject api = PyiCloudService("username", "password") calendar_service = api.calendar # Get a calendar to use calendars = calendar_service.get_calendars(as_objs=True) calendar_guid = calendars[0].guid # Create an event with proper validation event = EventObject( pguid=calendar_guid, title="Team Meeting", start_date=datetime.now() + timedelta(hours=2), end_date=datetime.now() + timedelta(hours=3), location="Conference Room A", all_day=False ) # Add invitees (they'll receive email notifications) event.add_invitees(["colleague1@company.com", "colleague2@company.com"]) # Add alarms event.add_alarm_before(minutes=15) # 15 minutes before event.add_alarm_before(days=1) # 1 day before # Add the event to the calendar calendar_service.add_event(event) ``` *Create a simple event:* ``` python # Basic event creation event = EventObject( pguid=calendar_guid, title="Doctor Appointment", start_date=datetime(2024, 1, 15, 14, 0), end_date=datetime(2024, 1, 15, 15, 0) ) # Add a 30-minute warning alarm event.add_alarm_before(minutes=30) calendar_service.add_event(event) ``` *Get events in a specific date range:* ``` python from_dt = datetime(2024, 1, 1) to_dt = datetime(2024, 1, 31) events = calendar_service.get_events(from_dt, to_dt, as_objs=True) for event in events: print(f"Event: {event.title} at {event.start_date}") ``` *Get next week's events:* ``` python next_week_events = calendar_service.get_events( from_dt=datetime.today() + timedelta(days=7), period="week", as_objs=True ) ``` *Remove an event:* ``` python calendar_service.remove_event(event) ``` ## 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) - Legacy service You can access documents stored in your iCloud account by using the `files` property\'s `dir` method: **NOTE** If you receive a `Account migrated` error, apple has migrated your account to iCloud drive. Please use the `api.drive` API instead. ``` 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'] ``` To delete an individual album, call the `delete` method. ``` pycon >>> api.photos.albums['MyAlbum'] >>> api.photos.albums['MyAlbum'].delete() True ``` 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 raw stream: ``` python photo = next(iter(api.photos.albums['Screenshots']), None) with open(photo.filename, 'wb') as opened_file: opened_file.write(photo.download()) ``` 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 with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: thumb_file.write(photo.download('thumb')) ``` To upload a photo use the `upload` method, which will upload the file to the requested album this will appear automatically in your 'ALL PHOTOS' album. This will return the uploaded PhotoAsset for further information. ``` python api.photos.albums['Screenshots'].upload(file_path) ``` ``` pycon >>> album = api.photos.albums['Screenshots'] >>> album >>> album.upload("./my_test_image.jpg") my_test_image.jpg ``` Note: Only limited media types are accepted. Unsupported types (e.g., PNG) will return a TYPE_UNSUPPORTED error. To delete a photo, use the `delete` method on the PhotoAsset. It returns a bool indicating success. ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> photo.delete() True ``` To add an existing photo to an album, use the `add_photo` method, which will link the PhotoAsset to the requested album. It returns a bool indicating success. ``` python api.photos.albums['Screenshots'].add_photo(photo_asset) ``` ``` pycon >>> photo = api.photos.albums['Screenshots'][0] >>> photo IMG_6045.JPG >>> my_album = api.photos.albums['MyAlbum'] >>> my_album >>> my_album.add_photo(photo) True ``` ## 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=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/SOURCES.txt0000644000175100017510000000420415133166715020230 0ustar00runnerrunner.gitignore .pre-commit-config.yaml LICENSE.txt README.md TERMS_OF_USE.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/cookie_jar.py pyicloud/exceptions.py pyicloud/session.py pyicloud/srp_password.py pyicloud/ssl_context.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/test_base.py tests/test_cmdline.py tests/test_cookie_jar.py tests/test_srp_password.py tests/test_ssl_context.py tests/test_utils.py tests/const/__init__.py tests/const/const.py tests/const/const_account.py tests/const/const_account_family.py tests/const/const_drive.py tests/const/const_findmyiphone.py tests/const/const_login.py tests/services/__init__.py tests/services/test_account.py tests/services/test_calendar.py tests/services/test_contacts.py tests/services/test_drive.py tests/services/test_findmyiphone.py tests/services/test_hidemyemail.py tests/services/test_photos.py tests/services/test_reminders.py tests/services/test_ubiquity.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/dependency_links.txt0000644000175100017510000000000115133166715022412 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/entry_points.txt0000644000175100017510000000006115133166715021637 0ustar00runnerrunner[console_scripts] icloud = pyicloud.cmdline:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/requires.txt0000644000175100017510000000042715133166715020747 0ustar00runnerrunnercertifi>=2024.12.14 click>=8.1.8 fido2>=2.0.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=1768746445.0 pyicloud-2.3.0/pyicloud.egg-info/top_level.txt0000644000175100017510000000001115133166715021066 0ustar00runnerrunnerpyicloud ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pylintrc0000644000175100017510000000163615133166711014613 0ustar00runnerrunner[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,pk [MESSAGES CONTROL] # Reasons disabled: # 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 disable= 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, too-many-positional-arguments, [FORMAT] expected-line-ending-format=LF [EXCEPTIONS] overgeneral-exceptions=pyicloud.exceptions.PyiCloudException ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/pyproject.toml0000644000175100017510000000536615133166711015744 0ustar00runnerrunner[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.10 target-version = "py310" [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", "conftest.py", "fetch_devices_error.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"] [tool.tox.env_run_base] description = "Run test under {base_python}" commands = [["pytest"]] deps = ["-r requirements_test.txt"] [tool.tox.gh-actions] python = ''' 3.10: 3.10, lint 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.10" license = "MIT" 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.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=1768746441.0 pyicloud-2.3.0/requirements.txt0000644000175100017510000000017615133166711016306 0ustar00runnerrunnercertifi>=2024.12.14 click>=8.1.8 fido2>=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=1768746441.0 pyicloud-2.3.0/requirements_all.txt0000644000175100017510000000007115133166711017130 0ustar00runnerrunner-r requirements.txt -r requirements_test.txt tox==4.33.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/requirements_test.txt0000644000175100017510000000022115133166711017334 0ustar00runnerrunnerisort>=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=1768746445.9315505 pyicloud-2.3.0/scripts/0000755000175100017510000000000015133166716014512 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/scripts/build.sh0000755000175100017510000000011315133166711016136 0ustar00runnerrunner#!/bin/bash set -euo pipefail mkdir -p dist rm -rf dist/* python -m build ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/scripts/pyenv.sh0000755000175100017510000000117215133166711016206 0ustar00runnerrunner#!/usr/bin/env zsh # Install pyenv if ! command -v pyenv &> /dev/null; then echo "pyenv not found, installing..." curl -fsSL https://pyenv.run | bash export PYENV_ROOT="$HOME/.pyenv" export PATH="$HOME/.pyenv/bin:$PATH" eval "$($HOME/.pyenv/bin/pyenv init -)" grep -qxF 'eval "$($HOME/.pyenv/bin/pyenv init -)"' ~/.zshrc || cat <>~/.zshrc export PYENV_ROOT="$HOME/.pyenv" export PATH="$HOME/.pyenv/bin:$PATH" eval "$($HOME/.pyenv/bin/pyenv init -)" EOF echo "pyenv installed successfully." else echo "pyenv already installed." fi pyenv install -sv 3.10 3.11 3.12 3.13 pyenv local 3.10 3.11 3.12 3.13 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/scripts/setup.sh0000755000175100017510000000051215133166711016202 0ustar00runnerrunner#!/usr/bin/zsh set -e export UV_LINK_MODE=copy uv venv --seed --clear && uv pip install -r requirements_all.txt grep -qx "source \"${WORKSPACE_DIRECTORY}/.venv/bin/activate\"" ~/.zshrc || cat <>~/.zshrc if [ -f "${WORKSPACE_DIRECTORY}/.venv/bin/activate" ]; then source "${WORKSPACE_DIRECTORY}/.venv/bin/activate" fi EOF ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9365506 pyicloud-2.3.0/setup.cfg0000644000175100017510000000004615133166716014644 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/sonar-project.properties0000644000175100017510000000043615133166711017725 0ustar00runnerrunnersonar.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.10,3.11,3.12,3.13 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9325504 pyicloud-2.3.0/tests/0000755000175100017510000000000015133166716014165 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/__init__.py0000644000175100017510000002403315133166711016273 0ustar00runnerrunner"""Library tests.""" # pylint: disable=protected-access import json from io import BytesIO from typing import Any, Optional from unittest.mock import MagicMock from requests import Response from pyicloud import base from tests.const import ( ACCOUNT_DEVICES_WORKING, ACCOUNT_FAMILY_WORKING, ACCOUNT_STORAGE_WORKING, AUTH_OK, AUTHENTICATED_USER, 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, FMI_FAMILY_WORKING, LOGIN_2FA, LOGIN_WORKING, REQUIRES_2FA_TOKEN, REQUIRES_2FA_USER, TRUSTED_DEVICE_1, TRUSTED_DEVICES, VALID_2FA_CODE, VALID_COOKIE, VALID_TOKEN, VALID_TOKENS, VALID_USERS, 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 if "/appleauth/auth/authorize/signin" in url and method == "GET": return ResponseMock( "", status_code=200, headers={ "scnt": "scnt_value", "X-Apple-Session-Id": "session_id_value", "X-Apple-Auth-Attributes": "auth_attributes_value", }, ) 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(MagicMock(), 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 return ResponseMock(VERIFICATION_CODE_OK) self._raise_error(MagicMock(), 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(MagicMock(), 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(MagicMock(), None, "Unknown reason") if data.get("accountName") == REQUIRES_2FA_USER: self._service.session._data["session_token"] = REQUIRES_2FA_TOKEN return ResponseMock(AUTH_OK) self._service.session._data["session_token"] = VALID_TOKEN 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(MagicMock(), None, "Incorrect code") self._service.session._data["session_token"] = VALID_TOKEN 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=1768746441.0 pyicloud-2.3.0/tests/conftest.py0000644000175100017510000001607315133166711016366 0ustar00runnerrunner"""Pytest configuration file for the pyicloud package.""" # pylint: disable=redefined-outer-name,protected-access import os import secrets from typing import Any 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.services.photos import BasePhotoLibrary, PhotoAsset from pyicloud.session import PyiCloudSession from tests import PyiCloudSessionMock from tests.const import LOGIN_WORKING BUILTINS_OPEN: str = "builtins.open" EXAMPLE_DOMAIN: str = "https://example.com" # pylint: disable=protected-access # pylint: disable=redefined-outer-name class FileSystemAccessError(Exception): """Raised when a test tries to access the file system.""" @pytest.fixture(autouse=True, scope="function") def mock_file_open_write_fixture(): """Mock the open function to prevent file system access.""" # Dictionary to store written data written_data: dict[str, Any] = {} 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 if "r" in mode: raise FileNotFoundError(f"No such file or directory: '{filepath}'") raise ValueError(f"Unsupported mode: {mode}") # Attach the written_data dictionary to the mock for access in tests mock_file_open.written_data = written_data # type: ignore return mock_file_open @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(autouse=True, scope="session") def mock_os_open_fixture(): """Mock the open function to prevent file system access.""" builtins_open = os.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("os.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.PyiCloudService.authenticate") as mock_authenticate, patch( "pyicloud.PyiCloudService._setup_cookie_directory" ) as mock_setup_cookie_directory, patch(BUILTINS_OPEN, new_callable=mock_open), ): # Mock the authenticate method during initialization mock_authenticate.return_value = None mock_setup_cookie_directory.return_value = "/tmp/pyicloud/cookies" service = PyiCloudService("test@example.com", secrets.token_hex(32)) return service @pytest.fixture def pyicloud_service_working(pyicloud_service: PyiCloudService) -> PyiCloudService: """Set the service to a working state.""" pyicloud_service.data = LOGIN_WORKING pyicloud_service._webservices = LOGIN_WORKING["webservices"] with patch(BUILTINS_OPEN, new_callable=mock_open): pyicloud_service._session = PyiCloudSessionMock( pyicloud_service, "", cookie_directory="", ) pyicloud_service.session._data = {"session_token": "valid_token"} check_pcs_consent = MagicMock( return_value={ "isICDRSDisabled": False, "isDeviceConsentedForPCS": True, } ) pyicloud_service._check_pcs_consent = check_pcs_consent return pyicloud_service @pytest.fixture def pyicloud_session(pyicloud_service_working: PyiCloudService) -> PyiCloudSession: """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: """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) -> BasePhotoLibrary: """Fixture for mocking PhotoLibrary.""" return BasePhotoLibrary( service=mock_photos_service, asset_type=PhotoAsset, ) @pytest.fixture def hidemyemail_service(mock_session: MagicMock) -> HideMyEmailService: """Fixture for initializing HideMyEmailService.""" return HideMyEmailService(EXAMPLE_DOMAIN, mock_session, {"dsid": "12345"}) @pytest.fixture def mock_service_with_cookies( pyicloud_service_working: PyiCloudService, ) -> 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 @pytest.fixture(autouse=True, scope="session") def mock_thread(): """Mock threading.Thread to prevent actual thread creation during tests.""" with patch("threading.Thread") as mock_thread_class: yield mock_thread_class ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9335506 pyicloud-2.3.0/tests/const/0000755000175100017510000000000015133166716015313 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/const/__init__.py0000644000175100017510000000310215133166711017413 0ustar00runnerrunner"""Test constants package.""" from .const import ( AUTHENTICATED_USER, REQUIRES_2FA_TOKEN, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_COOKIE, VALID_PASSWORD, VALID_TOKEN, VALID_TOKENS, VALID_USERS, ) from .const_account import ACCOUNT_DEVICES_WORKING, ACCOUNT_STORAGE_WORKING from .const_account_family import ACCOUNT_FAMILY_WORKING from .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 .const_findmyiphone import FMI_FAMILY_WORKING from .const_login import ( AUTH_OK, LOGIN_2FA, LOGIN_WORKING, TRUSTED_DEVICE_1, TRUSTED_DEVICES, VERIFICATION_CODE_KO, VERIFICATION_CODE_OK, ) __all__: list[str] = [ "AUTHENTICATED_USER", "REQUIRES_2FA_TOKEN", "REQUIRES_2FA_USER", "VALID_2FA_CODE", "VALID_COOKIE", "VALID_TOKEN", "VALID_TOKENS", "VALID_USERS", "VALID_PASSWORD", "ACCOUNT_DEVICES_WORKING", "ACCOUNT_STORAGE_WORKING", "ACCOUNT_FAMILY_WORKING", "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", "FMI_FAMILY_WORKING", "AUTH_OK", "LOGIN_2FA", "LOGIN_WORKING", "TRUSTED_DEVICE_1", "TRUSTED_DEVICES", "VERIFICATION_CODE_KO", "VERIFICATION_CODE_OK", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/const/const.py0000644000175100017510000000077415133166711017016 0ustar00runnerrunner"""Test constants.""" from .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=1768746441.0 pyicloud-2.3.0/tests/const/const_account.py0000644000175100017510000001052215133166711020522 0ustar00runnerrunner"""Account test constants.""" # pylint: disable=line-too-long from .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=1768746441.0 pyicloud-2.3.0/tests/const/const_account_family.py0000644000175100017510000000663415133166711022074 0ustar00runnerrunner"""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=1768746441.0 pyicloud-2.3.0/tests/const/const_drive.py0000644000175100017510000007215415133166711020210 0ustar00runnerrunner"""Drive test constants.""" # pylint: disable=line-too-long 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=1768746441.0 pyicloud-2.3.0/tests/const/const_findmyiphone.py0000644000175100017510000011163715133166711021570 0ustar00runnerrunner"""Find my iPhone test constants.""" from typing import Any from .const import CLIENT_ID from .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": "DONE", "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": "DONE", "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=1768746441.0 pyicloud-2.3.0/tests/const/const_login.py0000644000175100017510000003545515133166711020212 0ustar00runnerrunner"""Login test constants.""" from typing import Any from .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, "protocol": "s2k", "dsInfo": {"hsaVersion": 1}, "hsaChallengeRequired": False, "webservices": "TestWebservices", } ICLOUD_AUTH_URL = "https://idmsa.apple.com/appleauth/auth" 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": f"{ICLOUD_AUTH_URL}/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": f"{ICLOUD_AUTH_URL}/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, "ICDRSCapableDeviceCount": 1, "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": f"{ICLOUD_AUTH_URL}/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": f"{ICLOUD_AUTH_URL}/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, "ICDRSCapableDeviceCount": 1, "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} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768746445.9355505 pyicloud-2.3.0/tests/services/0000755000175100017510000000000015133166716016010 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/__init__.py0000644000175100017510000000000015133166711020102 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_account.py0000644000175100017510000001265615133166711021062 0ustar00runnerrunner"""Account service tests.""" # pylint: disable=protected-access from unittest.mock import MagicMock from pyicloud 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, params=pyicloud_service_working.account.params, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_calendar.py0000644000175100017510000006054715133166711021201 0ustar00runnerrunner"""Test calendar service""" # pylint: disable=protected-access from datetime import datetime from typing import Any from unittest.mock import MagicMock, patch import pytest from requests import Response from pyicloud.services.calendar import ( AlarmDefaults, AlarmMeasurement, AppleAlarm, AppleDateFormat, CalendarDefaults, CalendarObject, CalendarService, DateFormats, EventObject, ) from pyicloud.session import PyiCloudSession def test_event_object_initialization() -> None: """Test EventObject initialization and default values.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): event = EventObject(pguid="calendar123") assert event.pguid == "calendar123" assert event.title == "New Event" assert event.duration == 60 assert event.tz == "UTC" # Now tests dynamic timezone detection assert event.guid != "" def test_event_object_request_data() -> None: """Test EventObject request_data property.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): 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"] # Note: camelCase in output 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.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): 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.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): 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_event_object_dynamic_timezone() -> None: """Test that EventObject uses dynamic timezone detection based on user's locale.""" # Test with different timezones to ensure dynamic behavior with patch( "pyicloud.services.calendar.get_localzone_name", return_value="Europe/London" ): event = EventObject(pguid="calendar123") assert event.tz == "Europe/London" with patch( "pyicloud.services.calendar.get_localzone_name", return_value="Asia/Tokyo" ): event = EventObject(pguid="calendar123") assert event.tz == "Asia/Tokyo" with patch( "pyicloud.services.calendar.get_localzone_name", return_value="America/New_York" ): event = EventObject(pguid="calendar123") assert event.tz == "America/New_York" 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" # ===================================== # Tests for NEW Features Added in PR # ===================================== def test_constants_and_defaults() -> None: """Test all constant classes have expected values.""" # Test DateFormats assert DateFormats.API_DATE == "%Y-%m-%d" assert DateFormats.APPLE_DATE == "%Y%m%d" # Test CalendarDefaults assert CalendarDefaults.TITLE == "Untitled" assert CalendarDefaults.SYMBOLIC_COLOR == "__custom__" assert CalendarDefaults.SUPPORTED_TYPE == "Event" assert CalendarDefaults.OBJECT_TYPE == "personal" assert CalendarDefaults.ORDER == 7 assert CalendarDefaults.SHARE_TITLE == "" assert CalendarDefaults.SHARED_URL == "" assert CalendarDefaults.COLOR == "" # Test AlarmDefaults assert AlarmDefaults.MESSAGE_TYPE == "message" assert not AlarmDefaults.IS_LOCATION_BASED def _service_with_mocks(mock_session: PyiCloudSession) -> CalendarService: with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): return CalendarService("https://example.com", mock_session, {"dsid": "12345"}) class _FixedDateTime(datetime): """Subclass datetime to control today() for tests.""" fixed: datetime = datetime(2025, 2, 10) @classmethod def today(cls) -> "_FixedDateTime": # type: ignore[override] return cls.fromtimestamp(cls.fixed.timestamp()) def test_default_params_feb_non_leap() -> None: """default_params should compute Feb (non-leap) as 1..28.""" mock_session = MagicMock(spec=PyiCloudSession) service = _service_with_mocks(mock_session) # Freeze 'today' to 2025-02-10 (non-leap year) _FixedDateTime.fixed = datetime(2025, 2, 10) with ( patch("pyicloud.services.calendar.datetime", _FixedDateTime), patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"), ): params = service.default_params assert params["startDate"] == "2025-02-01" assert params["endDate"] == "2025-02-28" def test_default_params_feb_leap() -> None: """default_params should compute Feb (leap year) as 1..29.""" mock_session = MagicMock(spec=PyiCloudSession) service = _service_with_mocks(mock_session) # Freeze 'today' to 2028-02-10 (leap year) _FixedDateTime.fixed = datetime(2028, 2, 10) with ( patch("pyicloud.services.calendar.datetime", _FixedDateTime), patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"), ): params = service.default_params assert params["startDate"] == "2028-02-01" assert params["endDate"] == "2028-02-29" def test_refresh_client_anchors_from_dt_month() -> None: """When only from_dt is provided, anchor to its month for the end bound.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"Event": []} mock_session.get.return_value = mock_response service = _service_with_mocks(mock_session) from_dt = datetime(2025, 3, 15) with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service.refresh_client(from_dt=from_dt, to_dt=None) # Inspect params passed to GET _, kwargs = mock_session.get.call_args params = kwargs["params"] assert params["startDate"] == "2025-03-01" assert params["endDate"] == "2025-03-31" def test_refresh_client_anchors_to_dt_month() -> None: """When only to_dt is provided, anchor to its month for the start bound.""" mock_session = MagicMock(spec=PyiCloudSession) mock_response = MagicMock(spec=Response) mock_response.json.return_value = {"Event": []} mock_session.get.return_value = mock_response service = _service_with_mocks(mock_session) to_dt = datetime(2025, 4, 20) with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): service.refresh_client(from_dt=None, to_dt=to_dt) # Inspect params passed to GET _, kwargs = mock_session.get.call_args params = kwargs["params"] assert params["startDate"] == "2025-04-01" assert params["endDate"] == "2025-04-30" def test_apple_date_format_dataclass() -> None: """Test AppleDateFormat dataclass functionality.""" # Test from_datetime for start time dt = datetime(2023, 6, 15, 14, 30) apple_format = AppleDateFormat.from_datetime(dt, is_start=True) assert apple_format.date_string == "20230615" assert apple_format.year == 2023 assert apple_format.month == 6 assert apple_format.day == 15 assert apple_format.hour == 14 assert apple_format.minute == 30 assert apple_format.minutes_from_midnight == 870 # 14*60 + 30 # Test to_list conversion result_list = apple_format.to_list() expected = ["20230615", 2023, 6, 15, 14, 30, 870] assert result_list == expected # Test from_datetime for end time (different calculation) apple_format_end = AppleDateFormat.from_datetime(dt, is_start=False) assert ( apple_format_end.minutes_from_midnight == 630 ) # (24-14)*60 + (60-30) = 10*60 + 30 def test_calendar_object_uses_defaults() -> None: """Test CalendarObject uses constant defaults correctly.""" calendar = CalendarObject() assert calendar.title == CalendarDefaults.TITLE assert calendar.symbolic_color == CalendarDefaults.SYMBOLIC_COLOR assert calendar.supported_type == CalendarDefaults.SUPPORTED_TYPE assert calendar.object_type == CalendarDefaults.OBJECT_TYPE assert calendar.order == CalendarDefaults.ORDER assert calendar.share_title == CalendarDefaults.SHARE_TITLE assert calendar.shared_url == CalendarDefaults.SHARED_URL # Color gets generated, so just check it's not empty assert calendar.color.startswith("#") def test_event_object_validation() -> None: """Test EventObject validation logic.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): # Test empty pguid validation with pytest.raises(ValueError) as excinfo: EventObject(pguid="") assert "pguid cannot be empty" in str(excinfo.value) # Test empty pguid with whitespace with pytest.raises(ValueError) as excinfo: EventObject(pguid=" ") assert "pguid cannot be empty" in str(excinfo.value) # Test invalid date range (start after end) with pytest.raises(ValueError) as excinfo: EventObject( pguid="test-calendar", start_date=datetime(2023, 6, 15, 15, 0), end_date=datetime(2023, 6, 15, 14, 0), # Earlier than start ) assert "start_date" in str(excinfo.value) and "must be before end_date" in str( excinfo.value ) # Test valid event creation event = EventObject( pguid="test-calendar", title="Valid Event", start_date=datetime(2023, 6, 15, 14, 0), end_date=datetime(2023, 6, 15, 15, 0), ) assert event.pguid == "test-calendar" assert event.title == "Valid Event" assert event.duration == 60 # 1 hour in minutes def test_event_object_alarm_functionality() -> None: """Test alarm creation and management.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): event = EventObject(pguid="test-calendar", title="Alarm Test Event") # Test add_alarm_at_time alarm_guid = event.add_alarm_at_time() assert alarm_guid in event.alarms[0] # Format: "eventGuid:alarmGuid" assert len(event.alarms) == 1 assert event.alarms[0].startswith(event.guid) # Verify alarm metadata for "at time" alarm alarm_full_guid = event.alarms[0] assert alarm_full_guid in event._alarm_metadata metadata = event._alarm_metadata[alarm_full_guid] assert not metadata.before assert metadata.minutes == 0 assert metadata.hours == 0 # Test add_alarm_before with different time periods alarm_guid_5min = event.add_alarm_before(minutes=5) event.add_alarm_before(hours=1) # Don't need to store this one alarm_guid_complex = event.add_alarm_before(days=1, hours=2, minutes=30) assert len(event.alarms) == 4 # 1 at-time + 3 before alarms # Verify "5 minutes before" alarm metadata alarm_5min_full = f"{event.guid}:{alarm_guid_5min}" metadata_5min = event._alarm_metadata[alarm_5min_full] assert metadata_5min.before assert metadata_5min.minutes == 5 assert metadata_5min.hours == 0 assert metadata_5min.days == 0 # Verify "complex before" alarm metadata alarm_complex_full = f"{event.guid}:{alarm_guid_complex}" metadata_complex = event._alarm_metadata[alarm_complex_full] assert metadata_complex.before assert metadata_complex.days == 1 assert metadata_complex.hours == 2 assert metadata_complex.minutes == 30 def test_event_object_alarm_payload_structure() -> None: """Test alarm payload structure in request_data.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): event = EventObject(pguid="test-calendar", title="Alarm Test Event") # Add alarms event.add_alarm_at_time() event.add_alarm_before(minutes=15) # Get request data request_data = event.request_data # Verify Alarm array structure assert "Alarm" in request_data assert len(request_data["Alarm"]) == 2 # Check first alarm structure (at time) alarm1 = request_data["Alarm"][0] assert "guid" in alarm1 assert "pGuid" in alarm1 assert "messageType" in alarm1 assert "isLocationBased" in alarm1 assert "measurement" in alarm1 assert alarm1["pGuid"] == event.guid # Event GUID, not calendar GUID assert alarm1["messageType"] == AlarmDefaults.MESSAGE_TYPE assert alarm1["isLocationBased"] == AlarmDefaults.IS_LOCATION_BASED # Check measurement structure measurement1 = alarm1["measurement"] assert "before" in measurement1 assert "minutes" in measurement1 assert "hours" in measurement1 assert "days" in measurement1 # Verify Event.alarms field contains correct string format event_data = request_data["Event"] assert "alarms" in event_data assert len(event_data["alarms"]) == 2 assert all( ":" in alarm for alarm in event_data["alarms"] ) # Format: "eventGuid:alarmGuid" def test_event_object_invitee_payload_structure() -> None: """Test invitee payload structure in request_data.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): event = EventObject(pguid="test-calendar", title="Invitee Test Event") # Add invitees event.add_invitees(["test@example.com", "user@example.com"]) # Get request data request_data = event.request_data # Verify Invitee array structure assert "Invitee" in request_data assert len(request_data["Invitee"]) == 2 # Check first invitee structure invitee1 = request_data["Invitee"][0] assert "guid" in invitee1 assert "pGuid" in invitee1 assert "role" in invitee1 assert "isOrganizer" in invitee1 assert "email" in invitee1 assert "inviteeStatus" in invitee1 assert "commonName" in invitee1 assert "isMe" in invitee1 # Should be "isMe", not "isMyId" assert invitee1["pGuid"] == event.guid # Event GUID, not calendar GUID assert invitee1["email"] == "test@example.com" assert not invitee1["isMe"] # Verify Event.invitees field contains correct string format event_data = request_data["Event"] assert "invitees" in event_data assert len(event_data["invitees"]) == 2 assert event_data["invitees"][0] == f"{event.guid}:test@example.com" assert event_data["invitees"][1] == f"{event.guid}:user@example.com" def test_calendar_service_guid_bug_fix() -> None: """Test that GUID vs Calendar GUID bug is fixed.""" 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"} ) # Mock get_ctag to verify it's called with calendar GUID, not event GUID def mock_get_ctag(guid): # This should be called with the calendar GUID (event.pguid) # NOT the event GUID (event.guid) assert guid == "calendar-guid-123" return "test-ctag" service.get_ctag = mock_get_ctag # Create event with different event GUID and calendar GUID event = EventObject(pguid="calendar-guid-123", title="Test Event") event.guid = "event-guid-456" # Different from pguid # This should work - get_ctag should be called with calendar GUID response = service.add_event(event) assert response["status"] == "success" # For remove_event as well response = service.remove_event(event) assert response["status"] == "success" def test_complete_payload_structure() -> None: """Test that generated payload matches Apple's expected JSON structure.""" with patch("pyicloud.services.calendar.get_localzone_name", return_value="UTC"): event = EventObject( pguid="test-calendar-guid", title="Complete Test Event", start_date=datetime(2023, 6, 15, 14, 0), end_date=datetime(2023, 6, 15, 15, 0), location="Test Location", all_day=False, ) # Add invitees and alarms for complete test event.add_invitees(["test@example.com"]) event.add_alarm_before(minutes=15) request_data = event.request_data # Verify top-level structure expected_keys = ["Event", "Invitee", "Alarm", "ClientState"] for key in expected_keys: assert key in request_data, f"Missing top-level key: {key}" # Verify Event structure has camelCase fields event_data = request_data["Event"] camelcase_fields = [ "pGuid", "startDate", "endDate", "localStartDate", "localEndDate", "createdDate", "lastModifiedDate", "extendedDetailsAreIncluded", "recurrenceException", "recurrenceMaster", "hasAttachments", "shouldShowJunkUIWhenAppropriate", "changeRecurring", "allDay", ] for field in camelcase_fields: assert field in event_data, f"Missing camelCase field: {field}" # Verify date fields are in Apple's 7-element format assert isinstance(event_data["startDate"], list) assert len(event_data["startDate"]) == 7 assert event_data["startDate"][0] == "20230615" # YYYYMMDD string assert event_data["startDate"][1] == 2023 # Year assert event_data["startDate"][2] == 6 # Month # Verify ClientState structure client_state = request_data["ClientState"] assert "Collection" in client_state assert len(client_state["Collection"]) == 1 collection = client_state["Collection"][0] assert collection["guid"] == event.pguid # Calendar GUID, not event GUID assert "ctag" in collection def test_alarm_measurement_dataclass() -> None: """Test AlarmMeasurement dataclass.""" # Test default values measurement = AlarmMeasurement() assert measurement.before assert measurement.weeks == 0 assert measurement.days == 0 assert measurement.hours == 0 assert measurement.minutes == 0 assert measurement.seconds == 0 # Test custom values measurement = AlarmMeasurement(before=False, days=1, hours=2, minutes=30) assert not measurement.before assert measurement.days == 1 assert measurement.hours == 2 assert measurement.minutes == 30 def test_apple_alarm_dataclass() -> None: """Test AppleAlarm dataclass.""" measurement = AlarmMeasurement(before=True, minutes=15) alarm = AppleAlarm( guid="event-guid:alarm-guid", pGuid="event-guid", measurement=measurement ) assert alarm.guid == "event-guid:alarm-guid" assert alarm.pGuid == "event-guid" assert alarm.messageType == AlarmDefaults.MESSAGE_TYPE assert alarm.isLocationBased == AlarmDefaults.IS_LOCATION_BASED assert alarm.measurement.minutes == 15 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_contacts.py0000644000175100017510000000625515133166711021242 0ustar00runnerrunner"""Unit tests for the ContactsService and MeCard classes.""" # pylint: disable=protected-access 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" assert contacts_service._contacts_refresh_url == "https://example.com/co/startup" assert contacts_service._contacts_next_url == "https://example.com/co/contacts" assert ( contacts_service._contacts_changeset_url == "https://example.com/co/changeset" ) assert contacts_service._contacts_me_card_url == "https://example.com/co/mecard" assert contacts_service._contacts is None @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 == [ { "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=1768746441.0 pyicloud-2.3.0/tests/services/test_drive.py0000644000175100017510000005456715133166711020546 0ustar00runnerrunner"""Drive service tests.""" # pylint: disable=protected-access from typing import Optional from unittest.mock import ANY, Mock, patch import pytest from pyicloud 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"] 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, json=[{"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", 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}, json=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, json={ "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, json={ "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, json={ "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, json={ "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, json={ "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) 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", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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) 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", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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) mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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) mock_post.assert_called_once_with( drive._document_root + f"/ws/{CLOUD_DOCS_ZONE}/upload/web", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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", params=drive.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json=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", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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", params=ANY, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json={ "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", params=drive.params, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, json=ANY, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_findmyiphone.py0000644000175100017510000010344415133166711022113 0ustar00runnerrunner"""Find My iPhone service tests.""" # pylint: disable=protected-access from datetime import datetime, timedelta from unittest.mock import MagicMock, PropertyMock, call, patch import pytest from pyicloud import PyiCloudService from pyicloud.exceptions import ( PyiCloudAuthRequiredException, PyiCloudNoDevicesException, PyiCloudServiceUnavailable, ) from pyicloud.services.findmyiphone import ( AppleDevice, FindMyiPhoneServiceManager, _monitor_thread, ) from tests.const.const_findmyiphone import FMI_FAMILY_WORKING 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 with patch( "pyicloud.services.findmyiphone.AppleDevice.location_available", new_callable=PropertyMock, ) as mock_location_available: mock_location_available.return_value = False assert device.location is None # 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 with ( patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth" ) as mock_refresh, patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager.is_alive", new_callable=PropertyMock, ) as mock_is_alive, ): mock_is_alive.return_value = False assert "id" in device.data mock_refresh.assert_called_once() # Test model property assert device.model == device.data["deviceModel"] # Test device_type property assert device.device_type == device.data["deviceClass"] # Test __getitem__ method device_id = device.data["id"] with ( patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth" ) as mock_refresh, patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager.is_alive", new_callable=PropertyMock, ) as mock_is_alive, ): mock_is_alive.side_effect = [True, False] assert device["id"] == device_id mock_refresh.assert_not_called() assert device["id"] == device_id assert mock_refresh.call_count == 1 # Test __getattr__ method assert device.deviceDisplayName == device.data["deviceDisplayName"] display_name = device.data["deviceDisplayName"] with pytest.raises(AttributeError): _ = device.non_existent_attribute with ( patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth" ) as mock_refresh, patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager.is_alive", new_callable=PropertyMock, ) as mock_is_alive, ): mock_is_alive.return_value = False assert device.deviceDisplayName == display_name mock_refresh.assert_called_once() # 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 with pytest.raises(PyiCloudServiceUnavailable): device.data["features"] = { "WIP": False, "MSG": False, "LOC": False, "SND": False, } device.play_sound(subject="Test Alert") device.data["features"] = {"WIP": True, "MSG": True, "LOC": True, "SND": True} device.play_sound(subject="Test Alert") mock_post.assert_called_with( device._sound_url, params=device._params, json={ "device": device.data["id"], "subject": "Test Alert", "clientContext": {"fmly": True}, }, ) # Test display_message with pytest.raises(PyiCloudServiceUnavailable): device.data["features"] = { "WIP": False, "MSG": False, "LOC": False, "SND": False, } device.display_message(subject="Test Message", message="Hello", sounds=True) device.data["features"] = {"WIP": True, "MSG": True, "LOC": True, "SND": True} device.display_message(subject="Test Message", message="Hello", sounds=True) mock_post.assert_called_with( device._message_url, params=device._params, json={ "device": device.data["id"], "subject": "Test Message", "sound": True, "vibrate": False, "strobe": False, "userText": True, "text": "Hello", }, ) # Test lost_device with pytest.raises(PyiCloudServiceUnavailable): device.data["lostModeCapable"] = False device.lost_device( number="1234567890", text="Lost device message", newpasscode="1234" ) device.data["features"] = {"WIP": True, "MSG": True, "LOC": True, "SND": True} device.data["lostModeCapable"] = True device.lost_device( number="1234567890", text="Lost device message", newpasscode="1234" ) mock_post.assert_called_with( device._lost_url, params=device._params, json={ "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_with_reauth() assert len(manager) > 0 # Test __getitem__ with ( patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth" ) as mock_refresh, patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager.is_alive", new_callable=PropertyMock, ) as mock_is_alive, ): mock_is_alive.side_effect = [True, False, True, True] device: AppleDevice = manager[0] assert isinstance(device, AppleDevice) mock_refresh.assert_not_called() assert mock_is_alive.call_count == 1 device: AppleDevice = manager[0] assert isinstance(device, AppleDevice) assert mock_refresh.call_count == 1 assert mock_is_alive.call_count == 2 device: AppleDevice = manager[device.data["id"]] assert isinstance(device, AppleDevice) assert mock_refresh.call_count == 1 assert mock_is_alive.call_count == 4 # Test __str__ and __repr__ assert str(manager) == repr(manager) # Test __iter__ with ( patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth" ) as mock_refresh, patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager.is_alive", new_callable=PropertyMock, ) as mock_is_alive, ): mock_is_alive.side_effect = [True, True, False, False] devices: list[AppleDevice] = list(iter(manager)) assert len(devices) == len(manager) mock_refresh.assert_not_called() assert mock_is_alive.call_count == 2 devices: list[AppleDevice] = list(iter(manager)) assert len(devices) == len(manager) assert mock_refresh.call_count == 2 assert mock_is_alive.call_count == 4 assert len(manager.devices) == len(devices) assert manager.user_info == FMI_FAMILY_WORKING["userInfo"] def test_refresh_no_content(pyicloud_service_working: PyiCloudService) -> None: """Tests refresh_client handles no content response.""" with patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth", return_value=None, ): manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices manager._with_family = True with patch.object(manager.session, "post") as mock_post: mock_post.return_value.json.return_value = {} manager._refresh_client() assert mock_post.call_count == 1 assert len(manager._devices) == 0 mock_post.assert_called_with( url=manager._fmip_init_url, params=manager.params, json={ "clientContext": { "appName": "iCloud Find (Web)", "appVersion": "2.0", "apiVersion": "3.0", "deviceListVersion": 1, "fmly": True, "timezone": "US/Pacific", "inactiveTime": 0, } }, ) def test_refresh_with_server_ctx(pyicloud_service_working: PyiCloudService) -> None: """Tests refresh_client handles serverContext in response.""" with patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth", return_value=None, ): manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices manager._with_family = True with patch.object(manager.session, "post") as mock_post: mock_post.return_value.json.return_value = { "serverContext": { "theftLoss": { "status": "OFF", } }, "content": [], "error": None, } manager._refresh_client() manager._refresh_client() assert mock_post.call_count == 2 assert len(manager._devices) == 0 mock_post.assert_has_calls( [ call( url=manager._fmip_init_url, params=manager.params, json={ "clientContext": { "appName": "iCloud Find (Web)", "appVersion": "2.0", "apiVersion": "3.0", "deviceListVersion": 1, "fmly": True, "timezone": "US/Pacific", "inactiveTime": 0, } }, ), call().json(), call( url=manager._fmip_refresh_url, params=manager.params, json={ "clientContext": { "appName": "iCloud Find (Web)", "appVersion": "2.0", "apiVersion": "3.0", "deviceListVersion": 1, "fmly": True, "timezone": "US/Pacific", "inactiveTime": 0, }, "serverContext": {"theftLoss": None}, }, ), call().json(), ] ) def test_get_erase_token_success(pyicloud_service_working: PyiCloudService) -> None: """Tests AppleDevice._get_erase_token returns token when available.""" device: AppleDevice = pyicloud_service_working.devices[0] expected_token = "test_erase_token" with patch.object(device.session, "post") as mock_post: mock_post.return_value.json.return_value = { "tokens": {"mmeFMIPWebEraseDeviceToken": expected_token} } token: str = device._get_erase_token() assert token == expected_token mock_post.assert_called_with( url=device._erase_token_url, json={"dsWebAuthToken": device.session.data.get("session_token")}, ) def test_get_erase_token_missing_tokens( pyicloud_service_working: PyiCloudService, ) -> None: """Tests AppleDevice._get_erase_token raises when tokens missing.""" device: AppleDevice = pyicloud_service_working.devices[0] with patch.object(device.session, "post") as mock_post: mock_post.return_value.json.return_value = {} with pytest.raises(PyiCloudServiceUnavailable): device._get_erase_token() mock_post.assert_called_with( url=device._erase_token_url, json={"dsWebAuthToken": device.session.data.get("session_token")}, ) def test_get_erase_token_missing_token_key( pyicloud_service_working: PyiCloudService, ) -> None: """Tests AppleDevice._get_erase_token raises when token key missing.""" device: AppleDevice = pyicloud_service_working.devices[0] with ( patch.object(device.session, "post") as mock_post, pytest.raises(PyiCloudServiceUnavailable), ): mock_post.return_value.json.return_value = {"tokens": {}} device._get_erase_token() def test_erase_device_calls_post_with_correct_data( pyicloud_service_working: PyiCloudService, ) -> None: """Tests AppleDevice.erase_device calls session.post with correct data.""" device: AppleDevice = pyicloud_service_working.devices[0] expected_token = "test_erase_token" with ( patch.object( device, "_get_erase_token", return_value=expected_token ) as mock_get_token, patch.object(device.session, "post") as mock_post, ): device.erase_device(text="Erase this device", newpasscode="5678") mock_get_token.assert_called_once() mock_post.assert_called_with( device._erase_url, params=device._params, json={ "authToken": expected_token, "text": "Erase this device", "device": device.data["id"], "passcode": "5678", }, ) def test_erase_device_default_arguments( pyicloud_service_working: PyiCloudService, ) -> None: """Tests AppleDevice.erase_device with default arguments.""" device: AppleDevice = pyicloud_service_working.devices[0] expected_token = "default_token" with ( patch.object( device, "_get_erase_token", return_value=expected_token ) as mock_get_token, patch.object(device.session, "post") as mock_post, ): with pytest.raises(PyiCloudServiceUnavailable): device.data["features"] = { "WIP": False, } device.erase_device() device.data["features"] = { "WIP": True, } device.erase_device() mock_get_token.assert_called_once() mock_post.assert_called_with( device._erase_url, params=device._params, json={ "authToken": expected_token, "text": "This device has been lost. Please call me.", "device": device.data["id"], "passcode": "", }, ) def test_refresh_client_with_reauth_auth_required( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth handles PyiCloudAuthRequiredException and reauthenticates.""" manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices # Patch _refresh_client to raise PyiCloudAuthRequiredException first, then succeed with ( patch.object( manager, "_refresh_client", side_effect=[PyiCloudAuthRequiredException("", MagicMock()), None], ) as mock_refresh, patch.object(manager.session.service, "authenticate") as mock_authenticate, patch.object(manager, "_devices", {"dummy_id": "dummy_device"}), patch.object(manager, "_with_family", False), ): manager._refresh_client_with_reauth() mock_authenticate.assert_called_once_with(force_refresh=True) assert mock_refresh.call_count == 2 mock_refresh.assert_has_calls([call(locate=True), call(locate=True)]) def test_refresh_client_with_reauth_failed( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth handles PyiCloudAuthRequiredException and reauthenticates.""" manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices # Patch _refresh_client to raise PyiCloudAuthRequiredException first, then succeed with ( patch.object( manager, "_refresh_client", side_effect=[ PyiCloudAuthRequiredException("", MagicMock()), PyiCloudAuthRequiredException("", MagicMock()), ], ) as mock_refresh, patch.object(manager.session.service, "authenticate") as mock_authenticate, patch.object(manager, "_devices", {"dummy_id": "dummy_device"}), patch.object(manager, "_with_family", False), ): with pytest.raises(PyiCloudAuthRequiredException): manager._refresh_client_with_reauth() mock_authenticate.assert_called_once_with(force_refresh=True) assert mock_refresh.call_count == 2 mock_refresh.assert_has_calls([call(locate=True), call(locate=True)]) def test_refresh_client_with_reauth_with_locate( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth calls _refresh_client with locate=True.""" manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices manager._with_family = True with ( patch.object(manager, "_refresh_client") as mock_refresh, patch.object(manager, "_devices", {"dummy_id": "dummy_device"}), ): manager._refresh_client_with_reauth() # Should call _refresh_client once: with locate=True assert mock_refresh.call_count == 1 mock_refresh.assert_any_call(locate=True) def test_refresh_client_with_reauth_with_loading_to_done( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth calls _refresh_client if the members are loading.""" with patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth", return_value=None, ): manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices manager._with_family = True with ( patch("time.sleep", return_value=None), patch.object(manager, "_refresh_client") as mock_refresh, patch.object(manager, "_user_info") as mock_user_info, patch.object(manager, "_devices", {"dummy_id": "dummy_device"}), ): mock_user_info.__getitem__.return_value = True mock_user_info.get.side_effect = [ True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "LOADING", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "DONE", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, ] manager._refresh_client_with_reauth() assert mock_refresh.call_count == 3 mock_refresh.assert_any_call(locate=True) def test_refresh_client_with_reauth_with_loading_no_complete( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth calls _refresh_client if the members are loading.""" with patch( "pyicloud.services.findmyiphone.FindMyiPhoneServiceManager._refresh_client_with_reauth", return_value=None, ): manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices manager._with_family = True with ( patch("time.sleep", return_value=None), patch.object(manager, "_refresh_client") as mock_refresh, patch.object(manager, "_user_info") as mock_user_info, patch.object(manager, "_devices", {"dummy_id": "dummy_device"}), ): mock_user_info.__getitem__.return_value = True mock_user_info.get.side_effect = [ True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "LOADING", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "DONE", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, True, { "member1": { "firstName": "Member1", "lastName": "One", "appleId": "member1@example.com", "deviceFetchStatus": "LOADING", }, "member2": { "firstName": "Member2", "lastName": "Two", "appleId": "member2@example.com", "deviceFetchStatus": "DONE", }, }, ] manager._refresh_client_with_reauth() assert mock_refresh.call_count == 6 mock_refresh.assert_called_with(locate=True) def test_refresh_client_with_reauth_no_devices_raises( pyicloud_service_working: PyiCloudService, ) -> None: """Test refresh_client_with_reauth raises PyiCloudNoDevicesException when no devices.""" manager: FindMyiPhoneServiceManager = pyicloud_service_working.devices with ( patch.object(manager, "_refresh_client"), patch.object(manager, "_devices", {}), ): with pytest.raises(PyiCloudNoDevicesException): manager._refresh_client_with_reauth() def test_monitor_thread_calls_func_at_interval() -> None: """Test _monitor_thread calls function at specified interval.""" mock_func = MagicMock() interval = 0.2 with ( patch("pyicloud.services.findmyiphone.datetime") as mock_datetime, ): mock_event = MagicMock() mock_event.wait.side_effect = [False, False, True] # Mock datetime.now() to simulate time progression base_time = datetime(2023, 1, 1, 12, 0, 0) mock_datetime.now.side_effect = [ base_time, # Initial next_event calculation base_time + timedelta(seconds=0.1), # First loop check (not ready) base_time + timedelta(seconds=0.3), # Second loop check (ready) base_time + timedelta(seconds=0.3), # New next_event calculation ] mock_datetime.side_effect = datetime _monitor_thread(interval, mock_func, mock_event, locate=True) # Should call func once when interval has passed mock_func.assert_called_once_with(True) def test_monitor_thread_passes_locate_parameter() -> None: """Test _monitor_thread passes locate parameter to function.""" mock_func = MagicMock() with ( patch("pyicloud.services.findmyiphone.datetime") as mock_datetime, ): mock_event = MagicMock() mock_event.wait.side_effect = [False, True] base_time = datetime(2023, 1, 1, 12, 0, 0) mock_datetime.now.side_effect = [ base_time, # Initial next_event base_time + timedelta(seconds=1.0), # Loop check (ready) base_time + timedelta(seconds=1.0), # New next_event ] mock_datetime.side_effect = datetime _monitor_thread(0.5, mock_func, mock_event, locate=False) mock_func.assert_called_once_with(False) def test_monitor_thread_handles_exception() -> None: """Test _monitor_thread handles the function raising an exception.""" mock_func = MagicMock() mock_func.side_effect = Exception("Test Exception") with ( patch("pyicloud.services.findmyiphone.datetime") as mock_datetime, ): mock_event = MagicMock() mock_event.wait.side_effect = [False, True] base_time = datetime(2023, 1, 1, 12, 0, 0) mock_datetime.now.side_effect = [ base_time, # Initial next_event base_time + timedelta(seconds=1.0), # Loop check (ready) base_time + timedelta(seconds=1.0), # New next_event ] mock_datetime.side_effect = datetime _monitor_thread(0.5, mock_func, mock_event, locate=False) mock_func.assert_called_once_with(False) def test_monitor_thread_multiple_intervals() -> None: """Test _monitor_thread calls function multiple times across intervals.""" mock_func = MagicMock() interval = 0.1 with ( patch("pyicloud.services.findmyiphone.datetime") as mock_datetime, ): # Main thread alive for multiple iterations mock_event = MagicMock() mock_event.wait.side_effect = [False, False, False, True] base_time = datetime(2023, 1, 1, 12, 0, 0) mock_datetime.now.side_effect = [ base_time, # Initial next_event base_time + timedelta(seconds=0.15), # First ready base_time + timedelta(seconds=0.15), # New next_event after first call base_time + timedelta(seconds=0.25), # Next not ready (before interval) base_time + timedelta(seconds=0.26), # Second ready base_time + timedelta(seconds=0.30), # New next_event after second call ] mock_datetime.side_effect = datetime _monitor_thread(interval, mock_func, mock_event, locate=True) # Should call func twice assert mock_func.call_count == 2 mock_func.assert_has_calls([call(True), call(True)]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_hidemyemail.py0000644000175100017510000001266515133166711021715 0ustar00runnerrunner"""Tests for the Hide My Email service.""" # pylint: disable=protected-access 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"}, json={"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"}, json={"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"}, json={"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"}, json={"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"}, json={"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"}, json={"anonymousId": "12345"}, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_photos.py0000644000175100017510000020411315133166711020731 0ustar00runnerrunner"""PhotoLibrary tests.""" # pylint: disable=protected-access import base64 from datetime import datetime, timezone 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 ( PRIMARY_ZONE, AlbumContainer, AlbumTypeEnum, BasePhotoAlbum, BasePhotoLibrary, DirectionEnum, ListTypeEnum, ObjectTypeEnum, PhotoAlbum, PhotoAsset, 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", "recordChangeTag": "tag1", "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: AlbumContainer = library.albums 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", "recordChangeTag": "tag1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": False}, }, }, { "recordName": "1111-1111-1111-1111", "recordChangeTag": "tag2", "fields": { "albumNameEnc": {"value": "QWxidW0gTmFtZSAy"}, "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["Album Name 2"].id == "1111-1111-1111-1111" assert albums.index(1).id == "Time-lapse" assert albums.get("Nonexistent Album") is None assert albums[0] == next(iter(albums)) with pytest.raises(KeyError): _ = albums["Album Name 3"] with pytest.raises(IndexError): _ = albums.index(100) 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", "recordChangeTag": "tag1", "recordType": "CPLAsset", "fields": { "masterRef": {"value": {"recordName": "uploaded_photo"}} }, }, { "recordType": "CPLMaster", "recordName": "uploaded_photo", }, ] } ) ), ] 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")) as mock_file: asset: PhotoAsset | None = library.upload_file("test_photo.jpg") assert asset is not None assert asset.id == "uploaded_photo" mock_photos_service.session.post.assert_called_with( url="https://upload.example.com/upload?dsid=12345&filename=test_photo.jpg", data=mock_file.return_value, ) 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")) as mock_file: 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?dsid=12345&filename=test_photo.jpg", data=mock_file.return_value, ) 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")) as mock_file: result: PhotoAsset | None = library.upload_file("test_photo.jpg") assert result is None mock_photos_service.session.post.assert_called_with( url="https://upload.example.com/upload?dsid=12345&filename=test_photo.jpg", data=mock_file.return_value, ) 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", "recordChangeTag": "tag1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": False}, }, } ], "continuationMarker": "marker1", } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder2", "recordChangeTag": "tag2", "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: AlbumContainer = library.albums 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", "recordChangeTag": "tag1", "fields": { "albumNameEnc": {"value": "Zm9sZGVyMQ=="}, "isDeleted": {"value": True}, }, }, ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "folder2", "recordChangeTag": "tag2", "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: AlbumContainer = library.albums 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=ListTypeEnum.DEFAULT, page_size=50, direction=DirectionEnum.ASCENDING, ) assert album.name == "Test Album" assert album.service == mock_photo_library.service assert album.page_size == 50 def test_base_photo_album_parse_response() -> None: """Tests the _parse_response method.""" library = BasePhotoLibrary( service=MagicMock(), asset_type=PhotoAsset, ) response = { "records": [ { "recordType": "CPLAsset", "fields": {"masterRef": {"value": {"recordName": "master1"}}}, }, { "recordType": "CPLMaster", "recordName": "master1", }, ] } asset_records, master_records = library.parse_asset_response(response) 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=ListTypeEnum.DEFAULT, obj_type=ObjectTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, page_size=10, record_id="album1", 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=ListTypeEnum.DEFAULT, ) album._get_len = MagicMock(return_value=42) assert len(album) == 42 album._get_len.assert_called_once() 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=ListTypeEnum.DEFAULT, obj_type=ObjectTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, page_size=10, url="https://example.com/records/query?dsid=12345", record_id="album1", ) 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=ListTypeEnum.DEFAULT, ) 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=ListTypeEnum.DEFAULT, ) 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) assert isinstance(photos_service._shared_library, PhotoStreamLibrary) 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: dict[str, BasePhotoLibrary] = 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" ), json={ "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 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.find("Shared Album"), SharedPhotoStreamAlbum) mock_photos_service.session.post.assert_called() def test_photo_album_initialization(mock_photo_library: MagicMock) -> None: """Tests initialization of PhotoAlbum.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", query_filter=[ { "fieldName": "test", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "test"}, } ], zone_id={"zoneName": "TestZone"}, page_size=50, parent_id="parent123", record_change_tag="tag123", record_modification_date="2023-01-01T00:00:00Z", ) assert album.name == "Test Album" assert album.id == "album123" assert album._record_id == "album123" assert album._obj_type == ObjectTypeEnum.CONTAINER assert album._list_type == ListTypeEnum.CONTAINER assert album._direction == DirectionEnum.ASCENDING assert album._url == "https://example.com/records/query?dsid=12345" assert album._parent_id == "parent123" assert album._record_change_tag == "tag123" assert album._record_modification_date == "2023-01-01T00:00:00Z" assert album._zone_id == {"zoneName": "TestZone"} def test_photo_album_initialization_default_zone(mock_photo_library: MagicMock) -> None: """Tests PhotoAlbum initialization with default zone.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) assert album._zone_id == PRIMARY_ZONE def test_photo_album_fullname_no_parent(mock_photo_library: MagicMock) -> None: """Tests fullname property when album has no parent.""" album = PhotoAlbum( library=mock_photo_library, name="Root Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) assert album.fullname == "Root Album" def test_photo_album_fullname_with_parent() -> None: """Tests fullname property when album has a parent.""" mock_photo_library: MagicMock = MagicMock(spec=PhotoLibrary) parent_album = MagicMock() parent_album.fullname = "Parent Album" mock_albums = MagicMock() mock_albums.__getitem__.return_value = parent_album mock_photo_library.albums = mock_albums album = PhotoAlbum( library=mock_photo_library, name="Child Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", parent_id="parent123", ) assert album.fullname == "Parent Album/Child Album" mock_albums.__getitem__.assert_called_once_with("parent123") def test_photo_album_rename_success(mock_photos_service: MagicMock) -> None: """Tests successful album renaming.""" mock_photo_library: MagicMock = MagicMock(spec=PhotoLibrary) mock_photo_library.service = mock_photos_service mock_photo_library.service.session.post.return_value = MagicMock() mock_photo_library.service.service_endpoint = "https://example.com/endpoint" mock_photo_library.service.params = {"dsid": "12345"} album = PhotoAlbum( library=mock_photo_library, name="Old Name", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", record_change_tag="tag123", zone_id={"zoneName": "TestZone"}, ) album.rename("New Name") assert album._name == "New Name" expected_data = { "atomic": True, "zoneID": {"zoneName": "TestZone"}, "operations": [ { "operationType": "update", "record": { "recordName": "album123", "recordType": "CPLAlbum", "recordChangeTag": "tag123", "fields": { "albumNameEnc": { "value": base64.b64encode( "New Name".encode("utf-8") ).decode("utf-8"), }, }, }, } ], } mock_photo_library.service.session.post.assert_called_once_with( "https://example.com/endpoint/records/modify?dsid=12345", json=expected_data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) # Verify that if the server returns updated tags, they are stored mock_photo_library.service.session.post.return_value.json.return_value = { "records": [ { "recordChangeTag": "new_tag", "fields": {"recordModificationDate": {"value": "2023-02-01T00:00:00Z"}}, } ] } album.rename("Another Name") assert album._record_change_tag == "new_tag" assert album._record_modification_date == "2023-02-01T00:00:00Z" def test_photo_album_rename_same_name(mock_photo_library: MagicMock) -> None: """Tests that renaming to the same name does nothing.""" mock_photo_library.service.session.post.return_value = MagicMock() album = PhotoAlbum( library=mock_photo_library, name="Same Name", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) album.rename("Same Name") assert album._name == "Same Name" mock_photo_library.service.session.post.assert_not_called() def test_photo_album_delete_success(mock_photo_library: MagicMock) -> None: """Tests successful album deletion.""" mock_photo_library.service.session.post.return_value = MagicMock() mock_photo_library.service.service_endpoint = "https://example.com/endpoint" mock_photo_library.service.params = {"dsid": "12345"} album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", record_change_tag="tag123", zone_id={"zoneName": "TestZone"}, ) result = album.delete() assert result is True expected_data = { "atomic": True, "zoneID": {"zoneName": "TestZone"}, "operations": [ { "operationType": "update", "record": { "recordName": "album123", "recordChangeTag": "tag123", "recordType": "CPLAlbum", "fields": { "isDeleted": {"value": 1}, }, }, } ], } mock_photo_library.service.session.post.assert_called_once_with( "https://example.com/endpoint/records/modify?dsid=12345", json=expected_data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) def test_photo_album_upload_success(mock_photos_service: MagicMock) -> None: """Tests successful photo upload to album.""" mock_photo_library: MagicMock = MagicMock(spec=PhotoLibrary) mock_photo_asset = MagicMock() mock_photo_asset.id = "photo123" mock_photo_library.service = mock_photos_service mock_photo_library.upload_file.return_value = mock_photo_asset mock_photo_library.service.session.post.return_value = MagicMock() mock_photo_library.service.service_endpoint = "https://example.com/endpoint" mock_photo_library.service.params = {"dsid": "12345"} album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", zone_id={"zoneName": "TestZone"}, ) result = album.upload("/path/to/photo.jpg") assert result == mock_photo_asset mock_photo_library.upload_file.assert_called_once_with("/path/to/photo.jpg") expected_data = { "atomic": True, "zoneID": {"zoneName": "TestZone"}, "operations": [ { "operationType": "create", "record": { "fields": { "itemId": {"value": "photo123"}, "position": {"value": 1024}, "containerId": {"value": "album123"}, }, "recordType": "CPLContainerRelation", "recordName": "photo123-IN-album123", }, } ], } mock_photo_library.service.session.post.assert_called_with( "https://example.com/endpoint/records/modify?dsid=12345", json=expected_data, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) def test_photo_album_upload_not_photo_library() -> None: """Tests upload when library is not a PhotoLibrary instance.""" mock_library = MagicMock(spec=BasePhotoLibrary) album = PhotoAlbum( library=mock_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) result = album.upload("/path/to/photo.jpg") assert result is None def test_photo_album_upload_upload_file_returns_none() -> None: """Tests upload when upload_file returns None.""" mock_photo_library = MagicMock(spec=PhotoLibrary) mock_photo_library.upload_file.return_value = None album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) result: PhotoAsset | None = album.upload("/path/to/photo.jpg") mock_photo_library.upload_file.assert_called_once_with("/path/to/photo.jpg") assert result is None def test_photo_album_get_container_id(mock_photo_library: MagicMock) -> None: """Tests _get_container_id property.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) container_id = album._get_container_id assert container_id == f"{ObjectTypeEnum.CONTAINER.value}:album123" def test_photo_album_get_len(mock_photo_library: MagicMock) -> None: """Tests _get_len method.""" mock_response = {"batch": [{"records": [{"fields": {"itemCount": {"value": 42}}}]}]} mock_photo_library.service.session.post.return_value.json.return_value = ( mock_response ) mock_photo_library.service.service_endpoint = "https://example.com/endpoint" mock_photo_library.service.params = {"dsid": "12345"} album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", zone_id={"zoneName": "TestZone"}, ) length = album._get_len() assert length == 42 expected_json = { "batch": [ { "resultsLimit": 1, "query": { "recordType": "HyperionIndexCountLookup", "filterBy": { "fieldName": "indexCountID", "comparator": "IN", "fieldValue": { "type": "STRING_LIST", "value": [f"{ObjectTypeEnum.CONTAINER.value}:album123"], }, }, }, "zoneWide": True, "zoneID": {"zoneName": "TestZone"}, } ] } mock_photo_library.service.session.post.assert_called_once_with( "https://example.com/endpoint/internal/records/query/batch?dsid=12345", json=expected_json, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) def test_photo_album_get_payload(mock_photo_library: MagicMock) -> None: """Tests _get_payload method.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", query_filter=[ { "fieldName": "test", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "test"}, } ], zone_id={"zoneName": "TestZone"}, ) payload = album._get_payload( offset=10, page_size=20, direction=DirectionEnum.DESCENDING ) expected_payload = { "query": { "recordType": ListTypeEnum.CONTAINER.value, "filterBy": [ { "fieldName": "direction", "comparator": "EQUALS", "fieldValue": { "type": "STRING", "value": DirectionEnum.DESCENDING.value, }, }, { "fieldName": "startRank", "comparator": "EQUALS", "fieldValue": {"type": "INT64", "value": 10}, }, { "fieldName": "test", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "test"}, }, ], }, "resultsLimit": 20, "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": {"zoneName": "TestZone"}, } assert payload == expected_payload def test_photo_album_get_payload_no_query_filter(mock_photo_library: MagicMock) -> None: """Tests _get_payload method without query filter.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", zone_id={"zoneName": "TestZone"}, ) payload: dict[str, Any] = album._get_payload( offset=5, page_size=10, direction=DirectionEnum.ASCENDING ) # Verify that only the default filterBy entries are present assert len(payload["query"]["filterBy"]) == 2 assert payload["query"]["filterBy"][0]["fieldName"] == "direction" assert payload["query"]["filterBy"][1]["fieldName"] == "startRank" def test_photo_album_get_url(mock_photo_library: MagicMock) -> None: """Tests _get_url method.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", ) url = album._get_url() assert url == "https://example.com/records/query?dsid=12345" def test_photo_album_list_query_gen_with_filter(mock_photo_library: MagicMock) -> None: """Tests _list_query_gen method with query filter.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", zone_id={"zoneName": "TestZone"}, ) query_filter = [ { "fieldName": "custom", "comparator": "EQUALS", "fieldValue": {"type": "STRING", "value": "value"}, } ] query = album._list_query_gen( offset=0, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, num_results=50, query_filter=query_filter, ) # Verify that query filter is added to the filterBy array assert len(query["query"]["filterBy"]) == 3 assert query["query"]["filterBy"][2] == query_filter[0] def test_photo_album_list_query_gen_without_filter( mock_photo_library: MagicMock, ) -> None: """Tests _list_query_gen method without query filter.""" album = PhotoAlbum( library=mock_photo_library, name="Test Album", record_id="album123", obj_type=ObjectTypeEnum.CONTAINER, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, url="https://example.com/records/query?dsid=12345", zone_id={"zoneName": "TestZone"}, ) query = album._list_query_gen( offset=0, list_type=ListTypeEnum.CONTAINER, direction=DirectionEnum.ASCENDING, num_results=50, query_filter=None, ) # Verify that only default filterBy entries are present assert len(query["query"]["filterBy"]) == 2 assert query["query"]["filterBy"][0]["fieldName"] == "direction" assert query["query"]["filterBy"][1]["fieldName"] == "startRank" def test_photo_asset_properties_and_methods() -> None: """Test PhotoAsset properties and methods.""" # Prepare mock data for master and asset records filename = "test_photo.JPG" encoded_filename: str = base64.b64encode(filename.encode("utf-8")).decode("utf-8") now = int(datetime.now(tz=timezone.utc).timestamp() * 1000) master_record: dict[str, Any] = { "recordName": "photo_id_123", "fields": { "filenameEnc": {"value": encoded_filename}, "resOriginalRes": { "value": { "size": 123456, "downloadURL": "http://example.com/photo.jpg", } }, "resOriginalWidth": {"value": 1920}, "resOriginalHeight": {"value": 1080}, "itemType": {"value": "public.jpeg"}, "resOriginalFileType": {"value": "public.jpeg"}, "resJPEGThumbRes": { "value": { "size": 1234, "downloadURL": "http://example.com/thumb.jpg", } }, "resJPEGThumbWidth": {"value": 100}, "resJPEGThumbHeight": {"value": 50}, "resJPEGThumbFileType": {"value": "public.jpeg"}, }, "recordChangeTag": "tag1", } asset_record: dict[str, Any] = { "fields": { "assetDate": {"value": now}, "addedDate": {"value": now}, }, "recordName": "photo_id_123", "recordType": "CPLAsset", "zoneID": {"zoneName": "PrimarySync"}, } mock_service = MagicMock() mock_service.service_endpoint = "https://example.com" mock_service.params = {"dsid": "12345"} mock_service.session.get.return_value = MagicMock( json=MagicMock(return_value={}), raw=MagicMock(read=MagicMock(return_value=b"response")), ) mock_service.session.post.return_value = MagicMock( json=MagicMock(return_value={}), status_code=200 ) asset = PhotoAsset(mock_service, master_record, asset_record) # Test id assert asset.id == "photo_id_123" # Test filename assert asset.filename == filename # Test size assert asset.size == 123456 # Test created and asset_date assert isinstance(asset.created, datetime) assert isinstance(asset.asset_date, datetime) # Test added_date assert isinstance(asset.added_date, datetime) # Test dimensions assert asset.dimensions == (1920, 1080) # Test item_type assert asset.item_type == "image" # Test is_live_photo (should be False) assert asset.is_live_photo is False # Test versions versions: dict[str, dict[str, Any]] = asset.versions assert "original" in versions assert "thumb" in versions assert versions["original"]["filename"] == filename assert versions["original"]["url"] == "http://example.com/photo.jpg" assert versions["thumb"]["url"] == "http://example.com/thumb.jpg" # Test download returns the mocked response assert asset.download(version="original") == b"response" # Test download with invalid version returns None assert asset.download(version="nonexistent") is None # Test delete returns a mocked response resp: bool = asset.delete() assert resp is True # Test __repr__ assert repr(asset) == "" def test_photo_asset_is_live_photo_true() -> None: """Test PhotoAsset is_live_photo property for live photo.""" master_record: dict[str, Any] = { "recordName": "photo_id_456", "fields": { "filenameEnc": { "value": base64.b64encode(b"IMG_0001.HEIC").decode("utf-8") }, "resOriginalRes": { "value": { "size": 123456, "downloadURL": "http://example.com/photo.heic", } }, "resOriginalWidth": {"value": 4032}, "resOriginalHeight": {"value": 3024}, "itemType": {"value": "public.heic"}, "resOriginalFileType": {"value": "public.heic"}, "resOriginalVidComplFileType": {"value": "com.apple.quicktime-movie"}, "resVidSmallRes": { "value": { "size": 1000, "downloadURL": "http://example.com/video.mov", } }, "resVidSmallFileType": {"value": "com.apple.quicktime-movie"}, }, "recordChangeTag": "tag2", } asset_record: dict[str, Any] = { "fields": { "assetDate": {"value": 1700000000000}, "addedDate": {"value": 1700000000000}, }, "recordName": "photo_id_456", "recordType": "CPLAsset", "zoneID": {"zoneName": "PrimarySync"}, } mock_service = MagicMock() asset = PhotoAsset(mock_service, master_record, asset_record) assert asset.is_live_photo is True # The thumb_video version filename should end with .MOV thumb_video = asset.versions.get("thumb_video") if thumb_video: assert thumb_video["filename"].endswith(".MOV") @pytest.mark.parametrize( "master_fields,expected_type,filename", [ # itemType present and recognized ( { "itemType": {"value": "public.jpeg"}, "filenameEnc": { "value": base64.b64encode(b"photo.JPG").decode("utf-8") }, }, "image", "photo.JPG", ), ( { "itemType": {"value": "public.heic"}, "filenameEnc": { "value": base64.b64encode(b"photo.HEIC").decode("utf-8") }, }, "image", "photo.HEIC", ), ( { "itemType": {"value": "com.apple.quicktime-movie"}, "filenameEnc": { "value": base64.b64encode(b"movie.MOV").decode("utf-8") }, }, "movie", "movie.MOV", ), # itemType missing, resOriginalFileType present and recognized ( { "resOriginalFileType": {"value": "public.png"}, "filenameEnc": {"value": base64.b64encode(b"img.PNG").decode("utf-8")}, }, "image", "img.PNG", ), # itemType and resOriginalFileType missing, fallback to filename extension ( { "filenameEnc": { "value": base64.b64encode(b"fallback.JPG").decode("utf-8") } }, "image", "fallback.JPG", ), ( { "filenameEnc": { "value": base64.b64encode(b"fallback.HEIC").decode("utf-8") } }, "image", "fallback.HEIC", ), # itemType and resOriginalFileType missing, filename not image, fallback to movie ( { "filenameEnc": { "value": base64.b64encode(b"fallback.avi").decode("utf-8") } }, "movie", "fallback.avi", ), # itemType present but not recognized, fallback to filename extension ( { "itemType": {"value": "unknown.type"}, "filenameEnc": { "value": base64.b64encode(b"photo.JPG").decode("utf-8") }, }, "image", "photo.JPG", ), ( { "itemType": {"value": "unknown.type"}, "filenameEnc": { "value": base64.b64encode(b"video.avi").decode("utf-8") }, }, "movie", "video.avi", ), ], ) def test_photo_asset_item_type( master_fields: dict[str, Any], expected_type: str, filename: str ) -> None: """Test PhotoAsset item_type property with various scenarios.""" asset_record: dict[str, Any] = { "fields": { "assetDate": {"value": 1700000000000}, "addedDate": {"value": 1700000000000}, }, "recordName": "photo_id_test", "recordType": "CPLAsset", "zoneID": {"zoneName": "PrimarySync"}, } master_record: dict[str, Any] = { "recordName": "photo_id_test", "fields": master_fields, "recordChangeTag": "tag", } mock_service = MagicMock() asset = PhotoAsset(mock_service, master_record, asset_record) assert asset.filename == filename assert asset.item_type == expected_type def test_shared_photo_stream_album_properties() -> None: """Test SharedPhotoStreamAlbum properties and methods.""" # Setup test data name = "Shared Album" album_location = "https://shared.example.com/album/" album_ctag = "ctag" album_guid = "guid" owner_dsid = "owner" creation_date = str( int(datetime(2023, 1, 1, tzinfo=timezone.utc).timestamp() * 1000) ) sharing_type = "owned" allow_contributions = True is_public = False is_web_upload_supported = True public_url = "https://shared.example.com/public/album" page_size = 50 mock_library = MagicMock() album = SharedPhotoStreamAlbum( library=mock_library, name=name, album_location=album_location, album_ctag=album_ctag, album_guid=album_guid, owner_dsid=owner_dsid, creation_date=creation_date, sharing_type=sharing_type, allow_contributions=allow_contributions, is_public=is_public, is_web_upload_supported=is_web_upload_supported, public_url=public_url, page_size=page_size, ) assert album.id == album_guid assert album.fullname == name assert album.sharing_type == sharing_type assert album.allow_contributions is allow_contributions assert album.is_public is is_public assert album.is_web_upload_supported is is_web_upload_supported assert album.public_url == public_url assert isinstance(album.creation_date, datetime) assert album._album_location == album_location assert album._album_ctag == album_ctag assert album._album_guid == album_guid assert album._owner_dsid == owner_dsid def test_shared_photo_stream_album_get_payload_and_url_and_len( mock_photos_service: MagicMock, ) -> None: """Test SharedPhotoStreamAlbum _get_payload, _get_url, and _get_len.""" mock_library = MagicMock(spec=PhotoLibrary) mock_library.service = mock_photos_service mock_photos_service.params = {"dsid": "12345"} mock_photos_service.session.post.return_value.json.return_value = { "albumassetcount": 7 } mock_album = SharedPhotoStreamAlbum( library=mock_library, name="Shared Album", album_location="https://shared.example.com/album/", album_ctag="ctag", album_guid="guid", owner_dsid="owner", creation_date="1700000000000", ) # Test _get_payload payload = mock_album._get_payload( offset=2, page_size=5, direction=DirectionEnum.ASCENDING ) assert payload["albumguid"] == "guid" assert payload["albumctag"] == "ctag" assert payload["offset"] == "2" # limit should be offset+page_size or len(self), whichever is smaller # Since __len__ is not set, it will call _get_len, which returns 7 assert payload["limit"] == str(min(2 + 5, 7)) # Test _get_url url = mock_album._get_url() assert url.startswith("https://shared.example.com/album/webgetassets?") # Test _get_len length = mock_album._get_len() assert length == 7 mock_photos_service.session.post.assert_called_with( "https://shared.example.com/album/webgetassetcount?dsid=12345", json={"albumguid": "guid"}, headers={CONTENT_TYPE: CONTENT_TYPE_TEXT}, ) def test_shared_photo_stream_album_delete_and_rename_are_noops() -> None: """Test that delete returns False and rename returns None for SharedPhotoStreamAlbum.""" album = SharedPhotoStreamAlbum( library=MagicMock(), name="Shared Album", album_location="https://shared.example.com/album/", album_ctag="ctag", album_guid="guid", owner_dsid="owner", creation_date="1700000000000", ) assert album.delete() is False assert album.rename("New Name") is None def test_create_album_success(mock_photos_service: MagicMock) -> None: """Tests successful creation of an album.""" # Mock the POST response for indexing state mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "album123", "recordChangeTag": "tag123", "fields": { "albumNameEnc": { "value": base64.b64encode(b"My Album").decode( "utf-8" ) }, "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) album: PhotoAlbum | None = library.create_album("My Album") assert album is not None assert album.name == "My Album" assert album.id == "album123" # Check that the correct POST was made for album creation expected_data = { "operations": [ { "operationType": "create", "record": { "recordType": "CPLAlbum", "fields": { "albumNameEnc": { "value": base64.b64encode( "My Album".encode("utf-8") ).decode("utf-8"), }, "albumType": {"value": AlbumTypeEnum.ALBUM.value}, "isDeleted": {"value": 0}, "isExpunged": {"value": 0}, "sortType": {"value": 1}, "sortAscending": {"value": 1}, }, }, } ], "zoneID": {"zoneName": "PrimarySync"}, "atomic": True, } # The albumType value may be an enum, so just check the call was made assert mock_photos_service.session.post.call_count == 2 args, kwargs = mock_photos_service.session.post.call_args assert "records/modify" in args[0] assert ( kwargs["json"]["operations"][0]["record"]["fields"]["albumNameEnc"]["value"] == expected_data["operations"][0]["record"]["fields"]["albumNameEnc"]["value"] ) def test_create_album_returns_none_on_invalid_response( mock_photos_service: MagicMock, ) -> None: """Tests create_album returns None if _convert_record_to_album returns None.""" # Mock the POST response for indexing state mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "album123", "recordChangeTag": "tag123", "fields": { # Missing albumNameEnc triggers None return "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) album: PhotoAlbum | None = library.create_album("NoNameAlbum") assert album is None def test_create_album_with_custom_album_type(mock_photos_service: MagicMock) -> None: """Tests create_album with a custom album_type.""" # Mock the POST response for indexing state mock_photos_service.session.post.side_effect = [ MagicMock( json=MagicMock( return_value={ "records": [ { "fields": { "state": {"value": "FINISHED"}, }, } ], } ) ), MagicMock( json=MagicMock( return_value={ "records": [ { "recordName": "album456", "recordChangeTag": "tag456", "fields": { "albumNameEnc": { "value": base64.b64encode(b"Custom Album").decode( "utf-8" ) }, "isDeleted": {"value": False}, }, } ] } ) ), ] library = PhotoLibrary( service=mock_photos_service, zone_id={"zoneName": "PrimarySync"}, upload_url="https://upload.example.com", ) album: PhotoAlbum | None = library.create_album( "Custom Album", album_type=AlbumTypeEnum.ALBUM ) assert album is not None assert album.name == "Custom Album" assert album.id == "album456" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/services/test_reminders.py0000644000175100017510000001231415133166711021405 0ustar00runnerrunner"""Unit tests for the RemindersService class.""" # pylint: disable=protected-access import datetime 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["json"] data = kwargs["json"] 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 = kwargs["json"] 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 = kwargs["json"] 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=1768746441.0 pyicloud-2.3.0/tests/services/test_ubiquity.py0000644000175100017510000001206315133166711021271 0ustar00runnerrunner"""Unit tests for UbiquityService and UbiquityNode classes.""" # pylint: disable=protected-access 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_base.py0000644000175100017510000014207315133166711016512 0ustar00runnerrunner""" Test the PyiCloudService and PyiCloudSession classes.""" # pylint: disable=protected-access from typing import Any, List from unittest.mock import MagicMock, mock_open, patch import pytest from fido2.hid import CtapHidDevice from requests import HTTPError, Response from pyicloud import PyiCloudService from pyicloud.exceptions import ( PyiCloud2SARequiredException, PyiCloudAcceptTermsException, PyiCloudAPIResponseException, PyiCloudFailedLoginException, PyiCloudServiceNotActivatedException, PyiCloudServiceUnavailable, ) from pyicloud.services.calendar import CalendarService from pyicloud.services.contacts import ContactsService from pyicloud.services.hidemyemail import HideMyEmailService from pyicloud.services.photos import PhotosService from pyicloud.services.reminders import RemindersService from pyicloud.services.ubiquity import UbiquityService from pyicloud.session import PyiCloudSession from pyicloud.utils import b64_encode from tests.const import LOGIN_2FA 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.get") as mock_get_response, patch("pyicloud.base.PyiCloudSession.post") as mock_post_response, patch.object( pyicloud_service, "_authenticate_with_token", side_effect=[PyiCloudFailedLoginException("a"), None], ) as mock_authenticate_with_token, ): mock_post_response.return_value.json.side_effect = [ { "salt": "U29tZVNhbHQ=", "b": "U29tZUJ5dGVz", "c": "TestC", "protocol": "s2k", "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_get_response.call_count == 1 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._auth_data = { "fsaChallenge": { "challenge": challenge, # base64url(fake_challenge) "keyHandles": ["a2V5MQ", "a2V5Mg"], # base64url(fake_key_ids) "rpId": rp_id, } } # Simulated FIDO2 response mock_response = MagicMock() mock_response.response = MagicMock() mock_response.response.client_data = b"client_data" mock_response.response.signature = b"signature" mock_response.response.authenticator_data = b"auth_data" mock_response.response.user_handle = b"user_handle" mock_response.raw_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.response.client_data), "signatureData": b64_encode(mock_response.response.signature), "authenticatorData": b64_encode(mock_response.response.authenticator_data), "userHandle": b64_encode(mock_response.response.user_handle), "credentialID": b64_encode(mock_response.raw_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", } mock_session.post.return_value.json.return_value = { "termsUpdateNeeded": False, "hsaTrustedBrowser": True, } 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 = LOGIN_2FA 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("os.path.exists", return_value=True), patch("http.cookiejar.LWPCookieJar.save") as mock_save, patch("http.cookiejar.LWPCookieJar.load") as mock_load, ): 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( service=pyicloud_service_working, client_id="", 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_with( filename="testexamplecom.cookiejar", ignore_discard=True, ignore_expires=False, ) mock_load.assert_called_once_with( filename="testexamplecom.cookiejar", ignore_discard=True, ignore_expires=False, ) 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( code=401, reason="Missing X-APPLE-WEBAUTH-TOKEN cookie", response=MagicMock(), ) 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( code="ZONE_NOT_FOUND", reason="ServiceNotActivated", response=MagicMock() ) 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( code="ACCESS_DENIED", reason="ACCESS_DENIED", response=MagicMock() ) def test_request_pcs_for_service_icdrs_not_disabled( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service when ICDRS is not disabled (should early return).""" mock_logger = MagicMock() pyicloud_service._session = MagicMock() pyicloud_service.session.post = MagicMock( return_value=MagicMock(json=MagicMock(return_value={"isICDRSDisabled": False})) ) pyicloud_service.params = {} with patch("pyicloud.base.LOGGER", mock_logger): pyicloud_service._send_pcs_request = MagicMock() pyicloud_service._request_pcs_for_service("photos") mock_logger.warning.assert_called_once_with("ICDRS is not disabled") pyicloud_service._send_pcs_request.assert_not_called() def test_request_pcs_for_service_consent_needed_and_notification_sent( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service when device consent is needed and notification is sent.""" # First call: ICDRS disabled, device not consented # Second call: device consented (simulate after waiting) consent_states: List[dict[str, bool]] = [ {"isICDRSDisabled": True, "isDeviceConsentedForPCS": False}, {"isICDRSDisabled": True, "isDeviceConsentedForPCS": True}, ] pyicloud_service._check_pcs_consent = MagicMock(side_effect=consent_states) pyicloud_service._session = MagicMock() pyicloud_service.params = {} pyicloud_service._session.post.return_value.json.side_effect = [ {"isDeviceConsentNotificationSent": True}, {"status": "success", "message": "ok"}, ] with patch("time.sleep"): pyicloud_service._request_pcs_for_service("photos") pyicloud_service._session.post.assert_any_call( f"{pyicloud_service._setup_endpoint}/enableDeviceConsentForPCS", params=pyicloud_service.params, ) # Should not raise def test_request_pcs_for_service_consent_needed_and_notification_not_sent( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service when device consent notification is not sent (should raise).""" pyicloud_service._check_pcs_consent = MagicMock( return_value={"isICDRSDisabled": True, "isDeviceConsentedForPCS": False} ) pyicloud_service._session = MagicMock() pyicloud_service.params = {} pyicloud_service._session.post.return_value.json.return_value = { "isDeviceConsentNotificationSent": False } with pytest.raises( PyiCloudAPIResponseException, match="Unable to request PCS access!" ): pyicloud_service._request_pcs_for_service("photos") def test_request_pcs_for_service_pcs_consent_waits( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service waits for PCS consent and then proceeds.""" # Simulate PCS consent not granted for first 2 tries, then granted consent_states: List[dict[str, bool]] = [ {"isICDRSDisabled": True, "isDeviceConsentedForPCS": False}, {"isICDRSDisabled": True, "isDeviceConsentedForPCS": False}, {"isICDRSDisabled": True, "isDeviceConsentedForPCS": True}, ] pyicloud_service._check_pcs_consent = MagicMock(side_effect=consent_states) pyicloud_service._session = MagicMock() pyicloud_service.params = {} pyicloud_service._session.post.return_value.json.return_value = { "isDeviceConsentNotificationSent": True } pyicloud_service._send_pcs_request = MagicMock( return_value={"status": "success", "message": "ok"} ) with patch("time.sleep"): pyicloud_service._request_pcs_for_service("photos") assert pyicloud_service._send_pcs_request.called def test_request_pcs_for_service_success_on_first_attempt( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service grants PCS access on first attempt.""" pyicloud_service._check_pcs_consent = MagicMock( return_value={"isICDRSDisabled": True, "isDeviceConsentedForPCS": True} ) pyicloud_service._session = MagicMock() pyicloud_service.params = {} pyicloud_service._send_pcs_request = MagicMock( return_value={"status": "success", "message": "ok"} ) pyicloud_service._request_pcs_for_service("photos") pyicloud_service._send_pcs_request.assert_called_once_with( "photos", derived_from_user_action=True ) def test_request_pcs_for_service_retries_on_cookie_messages( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service retries on known cookie messages and succeeds.""" pyicloud_service._check_pcs_consent = MagicMock( return_value={"isICDRSDisabled": True, "isDeviceConsentedForPCS": True} ) pyicloud_service._session = MagicMock() pyicloud_service.params = {} responses: List[dict[str, str]] = [ {"status": "error", "message": "Requested the device to upload cookies."}, {"status": "error", "message": "Cookies not available yet on server."}, {"status": "success", "message": "ok"}, ] pyicloud_service._send_pcs_request = MagicMock(side_effect=responses) with patch("time.sleep"): pyicloud_service._request_pcs_for_service("photos") assert pyicloud_service._send_pcs_request.call_count == 3 def test_request_pcs_for_service_raises_on_unknown_message( pyicloud_service: PyiCloudService, ) -> None: """Test _request_pcs_for_service raises on unknown PCS state message.""" pyicloud_service._check_pcs_consent = MagicMock( return_value={"isICDRSDisabled": True, "isDeviceConsentedForPCS": True} ) pyicloud_service._session = MagicMock() pyicloud_service.params = {} pyicloud_service._send_pcs_request = MagicMock( return_value={"status": "error", "message": "Some unknown error"} ) mock_logger = MagicMock() with ( pytest.raises( PyiCloudAPIResponseException, match="Unable to request PCS access!" ), patch("pyicloud.base.LOGGER", mock_logger), ): pyicloud_service._request_pcs_for_service("photos") mock_logger.error.assert_called() def test_handle_accept_terms_no_terms_update_needed( pyicloud_service: PyiCloudService, ) -> None: """Test _handle_accept_terms when no terms update is needed (should do nothing).""" pyicloud_service.data = {"termsUpdateNeeded": False} login_data: dict[str, str] = {"test": "data"} # Should not raise or call anything pyicloud_service._session = MagicMock() pyicloud_service._accept_terms = True pyicloud_service._handle_accept_terms(login_data) pyicloud_service._session.get.assert_not_called() pyicloud_service._session.post.assert_not_called() def test_handle_accept_terms_terms_update_needed_accept_terms_false( pyicloud_service: PyiCloudService, ) -> None: """Test _handle_accept_terms when terms update is needed and accept_terms is False (should raise).""" pyicloud_service.data = {"termsUpdateNeeded": True} pyicloud_service._accept_terms = False login_data: dict[str, str] = {"test": "data"} with pytest.raises( PyiCloudAcceptTermsException, match="You must accept the updated terms of service", ): pyicloud_service._handle_accept_terms(login_data) def test_handle_accept_terms_terms_update_needed_accept_terms_true_success( pyicloud_service: PyiCloudService, ) -> None: """Test _handle_accept_terms when terms update is needed and accept_terms is True (should accept terms).""" pyicloud_service.data = { "termsUpdateNeeded": True, "dsInfo": {"languageCode": "en_US"}, } pyicloud_service._accept_terms = True login_data: dict[str, str] = {"test": "data"} # Mock session.get and session.post mock_get = MagicMock() mock_post = MagicMock() pyicloud_service.session.get = mock_get pyicloud_service.session.post = mock_post # Mock getTerms response get_terms_response = MagicMock() get_terms_response.raise_for_status = MagicMock() get_terms_response.json.return_value = {"iCloudTerms": {"version": 42}} mock_get.side_effect = [get_terms_response, get_terms_response] # Mock accountLogin response post_response = MagicMock() post_response.raise_for_status = MagicMock() post_response.json.return_value = {"new": "data"} mock_post.return_value = post_response pyicloud_service._handle_accept_terms(login_data) # Check calls mock_get.assert_any_call( f"{pyicloud_service._setup_endpoint}/getTerms", params=pyicloud_service.params, json={"locale": "en_US"}, ) mock_get.assert_any_call( f"{pyicloud_service._setup_endpoint}/repairDone", params=pyicloud_service.params, json={"acceptedICloudTerms": 42}, ) mock_post.assert_called_once_with( f"{pyicloud_service._setup_endpoint}/accountLogin", json=login_data ) assert pyicloud_service.data == {"new": "data"} def test_handle_accept_terms_terms_update_needed_accept_terms_true_http_error( pyicloud_service: PyiCloudService, ) -> None: """Test _handle_accept_terms when terms update is needed and accept_terms is True but HTTP error occurs.""" pyicloud_service.data = { "termsUpdateNeeded": True, "dsInfo": {"languageCode": "en_US"}, } pyicloud_service._accept_terms = True login_data: dict[str, str] = {"test": "data"} # Mock session.get to raise HTTPError mock_get = MagicMock() pyicloud_service.session.get = mock_get mock_get.side_effect = HTTPError("HTTP error") with pytest.raises(HTTPError): pyicloud_service._handle_accept_terms(login_data) def test_handle_accept_terms_terms_update_needed_accept_terms_true_post_error( pyicloud_service: PyiCloudService, ) -> None: """Test _handle_accept_terms when terms update is needed and accept_terms is True but POST raises HTTPError.""" pyicloud_service.data = { "termsUpdateNeeded": True, "dsInfo": {"languageCode": "en_US"}, } pyicloud_service._accept_terms = True login_data: dict[str, str] = {"test": "data"} # Mock session.get for getTerms and repairDone mock_get = MagicMock() pyicloud_service.session.get = mock_get get_terms_response = MagicMock() get_terms_response.raise_for_status = MagicMock() get_terms_response.json.return_value = {"iCloudTerms": {"version": 42}} mock_get.side_effect = [get_terms_response, get_terms_response] # Mock session.post to raise HTTPError mock_post = MagicMock() pyicloud_service.session.post = mock_post mock_post.side_effect = HTTPError("POST error") with pytest.raises(HTTPError): pyicloud_service._handle_accept_terms(login_data) def test_validate_token_success(pyicloud_service: PyiCloudService) -> None: """Test _validate_token returns JSON when X-APPLE-WEBAUTH-TOKEN is present and request succeeds.""" with ( patch.object(pyicloud_service.session.cookies, "get", return_value="token"), patch.object(pyicloud_service.session, "post") as mock_post, ): mock_response = MagicMock() mock_response.json.return_value = {"status": "success"} mock_post.return_value = mock_response result = pyicloud_service._validate_token() assert result == {"status": "success"} mock_post.assert_called_once_with( f"{pyicloud_service._setup_endpoint}/validate", data="null" ) def test_validate_token_missing_cookie_raises( pyicloud_service: PyiCloudService, ) -> None: """Test _validate_token raises when X-APPLE-WEBAUTH-TOKEN cookie is missing.""" with patch.object(pyicloud_service.session.cookies, "get", return_value=None): with pytest.raises( PyiCloudAPIResponseException, match="Missing X-APPLE-WEBAUTH-TOKEN cookie" ): pyicloud_service._validate_token() def test_validate_token_post_raises_exception( pyicloud_service: PyiCloudService, ) -> None: """Test _validate_token raises when session.post raises PyiCloudAPIResponseException.""" with ( patch.object(pyicloud_service.session.cookies, "get", return_value="token"), patch.object( pyicloud_service.session, "post", side_effect=PyiCloudAPIResponseException("Invalid token"), ), ): with pytest.raises(PyiCloudAPIResponseException, match="Invalid token"): pyicloud_service._validate_token() def test_str_and_repr(pyicloud_service: PyiCloudService) -> None: """Test __str__ and __repr__ methods.""" s = str(pyicloud_service) r: str = repr(pyicloud_service) assert s.startswith("iCloud API:") assert r.startswith(" None: """Test account_name property returns the correct Apple ID.""" assert pyicloud_service.account_name == pyicloud_service._apple_id def test_requires_2sa_true(pyicloud_service: PyiCloudService) -> None: """Test requires_2sa returns True when hsaVersion >= 1 and not trusted.""" pyicloud_service.data = { "dsInfo": {"hsaVersion": 1}, "hsaChallengeRequired": True, "hsaTrustedBrowser": False, } assert pyicloud_service.requires_2sa def test_requires_2sa_false(pyicloud_service: PyiCloudService) -> None: """Test requires_2sa returns False when hsaVersion < 1.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 0}} assert not pyicloud_service.requires_2sa def test_requires_2fa_true(pyicloud_service: PyiCloudService) -> None: """Test requires_2fa returns True when hsaVersion == 2 and not trusted.""" pyicloud_service.data = { "dsInfo": {"hsaVersion": 2}, "hsaChallengeRequired": True, "hsaTrustedBrowser": False, } assert pyicloud_service.requires_2fa def test_requires_2fa_false(pyicloud_service: PyiCloudService) -> None: """Test requires_2fa returns False when hsaVersion != 2.""" pyicloud_service.data = {"dsInfo": {"hsaVersion": 1}} assert not pyicloud_service.requires_2fa def test_is_trusted_session_true(pyicloud_service: PyiCloudService) -> None: """Test is_trusted_session returns True when hsaTrustedBrowser is True.""" pyicloud_service.data = {"hsaTrustedBrowser": True} assert pyicloud_service.is_trusted_session def test_is_trusted_session_false(pyicloud_service: PyiCloudService) -> None: """Test is_trusted_session returns False when hsaTrustedBrowser is False.""" pyicloud_service.data = {"hsaTrustedBrowser": False} assert not pyicloud_service.is_trusted_session def test_get_auth_headers_overrides(pyicloud_service: PyiCloudService) -> None: """Test _get_auth_headers applies overrides.""" pyicloud_service.session.data["scnt"] = "test_scnt" pyicloud_service.session.data["session_id"] = "test_session_id" headers: dict[str, Any] = pyicloud_service._get_auth_headers( {"Extra-Header": "Value"} ) assert headers["scnt"] == "test_scnt" assert headers["X-Apple-ID-Session-Id"] == "test_session_id" assert headers["Extra-Header"] == "Value" def test_trusted_devices_calls_session_get(pyicloud_service: PyiCloudService) -> None: """Test trusted_devices property calls session.get and returns devices.""" mock_response = MagicMock() mock_response.json.return_value = {"devices": [{"id": "device1"}]} pyicloud_service.session.get = MagicMock(return_value=mock_response) devices: list[dict[str, Any]] = pyicloud_service.trusted_devices assert devices == [{"id": "device1"}] pyicloud_service.session.get.assert_called_once() def test_send_verification_code_success(pyicloud_service: PyiCloudService) -> None: """Test send_verification_code returns True on success.""" mock_response = MagicMock() mock_response.json.return_value = {"success": True} pyicloud_service.session.post = MagicMock(return_value=mock_response) result = pyicloud_service.send_verification_code({"id": "device1"}) assert result is True def test_send_verification_code_failure(pyicloud_service: PyiCloudService) -> None: """Test send_verification_code returns False on failure.""" mock_response = MagicMock() mock_response.json.return_value = {"success": False} pyicloud_service.session.post = MagicMock(return_value=mock_response) result: bool = pyicloud_service.send_verification_code({"id": "device1"}) assert result is False def test_validate_verification_code_success(pyicloud_service: PyiCloudService) -> None: """Test validate_verification_code returns True when code is valid.""" pyicloud_service.session.post = MagicMock() pyicloud_service.trust_session = MagicMock(return_value=True) result: bool = pyicloud_service.validate_verification_code( {"id": "device1"}, "123456" ) assert result is True def test_validate_verification_code_wrong_code( pyicloud_service: PyiCloudService, ) -> None: """Test validate_verification_code returns False on wrong code.""" exc = PyiCloudAPIResponseException("Invalid code") exc.code = -21669 pyicloud_service.session.post = MagicMock(side_effect=exc) result: bool = pyicloud_service.validate_verification_code( {"id": "device1"}, "000000" ) assert result is False def test_validate_verification_code_raises_other( pyicloud_service: PyiCloudService, ) -> None: """Test validate_verification_code raises on unknown error.""" exc = PyiCloudAPIResponseException("Other error") exc.code = 12345 pyicloud_service.session.post = MagicMock(side_effect=exc) with pytest.raises(PyiCloudAPIResponseException): pyicloud_service.validate_verification_code({"id": "device1"}, "000000") def test_security_key_names_returns_key_names( pyicloud_service: PyiCloudService, ) -> None: """Test security_key_names property returns keyNames from options.""" pyicloud_service._auth_data = {"keyNames": ["key1", "key2"]} assert pyicloud_service.security_key_names == ["key1", "key2"] def test_fido2_devices_lists_devices(pyicloud_service: PyiCloudService) -> None: """Test fido2_devices property lists devices.""" with patch( "pyicloud.base.CtapHidDevice.list_devices", return_value=[MagicMock()] ) as mock_list: devices: List[CtapHidDevice] = pyicloud_service.fido2_devices assert isinstance(devices, list) mock_list.assert_called_once() def test_confirm_security_key_no_devices_raises( pyicloud_service: PyiCloudService, ) -> None: """Test confirm_security_key raises if no FIDO2 devices found.""" pyicloud_service._auth_data = { "fsaChallenge": {"challenge": "c", "keyHandles": [], "rpId": "rp"} } with patch("pyicloud.base.CtapHidDevice.list_devices", return_value=[]): with pytest.raises(RuntimeError, match="No FIDO2 devices found"): pyicloud_service.confirm_security_key() def test_get_webservice_url_raises_if_missing( pyicloud_service: PyiCloudService, ) -> None: """Test get_webservice_url raises if key missing.""" pyicloud_service._webservices = None with pytest.raises(PyiCloudServiceNotActivatedException): pyicloud_service.get_webservice_url("missing_key") def test_get_webservice_url_returns_url(pyicloud_service: PyiCloudService) -> None: """Test get_webservice_url returns correct url.""" pyicloud_service._webservices = {"foo": {"url": "https://foo.com"}} assert pyicloud_service.get_webservice_url("foo") == "https://foo.com" def test_str_returns_expected_format(pyicloud_service: PyiCloudService) -> None: """Test __str__ method.""" assert str(pyicloud_service).startswith("iCloud API:") def test_repr_returns_expected_format(pyicloud_service: PyiCloudService) -> None: """Test __repr__ method.""" assert repr(pyicloud_service).startswith(" None: """Test account_name property returns the correct Apple ID.""" assert pyicloud_service.account_name == pyicloud_service._apple_id def test_hidemyemail_returns_service(pyicloud_service: PyiCloudService) -> None: """Test hidemyemail property returns HideMyEmailService instance.""" mock_hme_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://hme.example.com", ), patch( "pyicloud.base.HideMyEmailService", return_value=mock_hme_service ) as mock_hme_cls, ): pyicloud_service._hidemyemail = None result: HideMyEmailService = pyicloud_service.hidemyemail mock_hme_cls.assert_called_once_with( service_root="https://hme.example.com", session=pyicloud_service.session, params=pyicloud_service.params, ) assert result == mock_hme_service def test_hidemyemail_returns_cached_instance(pyicloud_service: PyiCloudService) -> None: """Test hidemyemail property returns cached instance if already set.""" mock_hme_service = MagicMock() pyicloud_service._hidemyemail = mock_hme_service result: HideMyEmailService = pyicloud_service.hidemyemail assert result == mock_hme_service def test_hidemyemail_raises_on_api_exception(pyicloud_service: PyiCloudService) -> None: """Test hidemyemail property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://hme.example.com", ), patch( "pyicloud.base.HideMyEmailService", side_effect=PyiCloudAPIResponseException("error"), ), ): pyicloud_service._hidemyemail = None with pytest.raises( PyiCloudServiceUnavailable, match="Hide My Email service not available" ): _: HideMyEmailService = pyicloud_service.hidemyemail def test_files_returns_service(pyicloud_service: PyiCloudService) -> None: """Test files property returns UbiquityService instance.""" mock_files_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://files.example.com", ), patch( "pyicloud.base.UbiquityService", return_value=mock_files_service ) as mock_files_cls, ): pyicloud_service._files = None result: UbiquityService = pyicloud_service.files mock_files_cls.assert_called_once_with( service_root="https://files.example.com", session=pyicloud_service.session, params=pyicloud_service.params, ) assert result == mock_files_service def test_files_returns_cached_instance( pyicloud_service: PyiCloudService, ) -> None: """Test files property returns cached instance if already set.""" mock_files_service = MagicMock() pyicloud_service._files = mock_files_service result: UbiquityService = pyicloud_service.files assert result == mock_files_service def test_files_raises_on_api_exception( pyicloud_service: PyiCloudService, ) -> None: """Test files property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://files.example.com", ), patch( "pyicloud.base.UbiquityService", side_effect=PyiCloudAPIResponseException("error"), ), ): pyicloud_service._files = None with pytest.raises( PyiCloudServiceUnavailable, match="Files service not available" ): _: UbiquityService = pyicloud_service.files def test_files_raises_on_account_migrated( pyicloud_service: PyiCloudService, ) -> None: """Test files property raises specific message if Account migrated.""" exc = PyiCloudAPIResponseException("Account migrated") exc.reason = "Account migrated" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://files.example.com", ), patch( "pyicloud.base.UbiquityService", side_effect=exc, ), ): pyicloud_service._files = None with pytest.raises( PyiCloudServiceUnavailable, match="Files service not available use `api.drive` instead", ): _: UbiquityService = pyicloud_service.files def test_photos_returns_service(pyicloud_service: PyiCloudService) -> None: """Test photos property returns PhotosService instance.""" mock_photos_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", side_effect=[ "https://photos.example.com", "https://upload.example.com", "https://shared.example.com", ], ), patch( "pyicloud.base.PhotosService", return_value=mock_photos_service ) as mock_photos_cls, patch.object(pyicloud_service, "_request_pcs_for_service"), ): pyicloud_service._photos = None pyicloud_service.data = {"dsInfo": {"dsid": "12345"}} result: PhotosService = pyicloud_service.photos mock_photos_cls.assert_called_once_with( service_root="https://photos.example.com", session=pyicloud_service.session, params=pyicloud_service.params, upload_url="https://upload.example.com", shared_streams_url="https://shared.example.com", ) assert pyicloud_service.params["dsid"] == "12345" assert result == mock_photos_service def test_photos_returns_cached_instance( pyicloud_service: PyiCloudService, ) -> None: """Test photos property returns cached instance if already set.""" mock_photos_service = MagicMock() pyicloud_service._photos = mock_photos_service with patch.object(pyicloud_service, "_request_pcs_for_service"): result: PhotosService = pyicloud_service.photos assert result == mock_photos_service def test_photos_raises_on_api_exception( pyicloud_service: PyiCloudService, ) -> None: """Test photos property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", side_effect=[ "https://photos.example.com", "https://upload.example.com", "https://shared.example.com", ], ), patch( "pyicloud.base.PhotosService", side_effect=PyiCloudAPIResponseException("error"), ), patch.object(pyicloud_service, "_request_pcs_for_service"), ): pyicloud_service._photos = None pyicloud_service.data = {"dsInfo": {"dsid": "12345"}} with pytest.raises( PyiCloudServiceUnavailable, match="Photos service not available" ): _: PhotosService = pyicloud_service.photos def test_calendar_returns_service( pyicloud_service: PyiCloudService, ) -> None: """Test calendar property returns CalendarService instance.""" mock_calendar_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://calendar.example.com", ), patch( "pyicloud.base.CalendarService", return_value=mock_calendar_service, ) as mock_calendar_cls, ): pyicloud_service._calendar = None result: CalendarService = pyicloud_service.calendar mock_calendar_cls.assert_called_once_with( service_root="https://calendar.example.com", session=pyicloud_service.session, params=pyicloud_service.params, ) assert result == mock_calendar_service def test_calendar_returns_cached_instance( pyicloud_service: PyiCloudService, ) -> None: """Test calendar property returns cached instance if already set.""" mock_calendar_service = MagicMock() pyicloud_service._calendar = mock_calendar_service result: CalendarService = pyicloud_service.calendar assert result == mock_calendar_service def test_calendar_raises_on_api_exception( pyicloud_service: PyiCloudService, ) -> None: """Test calendar property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://calendar.example.com", ), patch( "pyicloud.base.CalendarService", side_effect=PyiCloudAPIResponseException("error"), ), ): pyicloud_service._calendar = None with pytest.raises( PyiCloudServiceUnavailable, match="Calendar service not available", ): _: CalendarService = pyicloud_service.calendar def test_contacts_returns_service( pyicloud_service: PyiCloudService, ) -> None: """Test contacts property returns ContactsService instance.""" mock_contacts_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://contacts.example.com", ), patch( "pyicloud.base.ContactsService", return_value=mock_contacts_service, ) as mock_contacts_cls, ): pyicloud_service._contacts = None result: ContactsService = pyicloud_service.contacts mock_contacts_cls.assert_called_once_with( service_root="https://contacts.example.com", session=pyicloud_service.session, params=pyicloud_service.params, ) assert result == mock_contacts_service def test_contacts_returns_cached_instance( pyicloud_service: PyiCloudService, ) -> None: """Test contacts property returns cached instance if already set.""" mock_contacts_service = MagicMock() pyicloud_service._contacts = mock_contacts_service result: ContactsService = pyicloud_service.contacts assert result == mock_contacts_service def test_contacts_raises_on_api_exception( pyicloud_service: PyiCloudService, ) -> None: """Test contacts property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://contacts.example.com", ), patch( "pyicloud.base.ContactsService", side_effect=PyiCloudAPIResponseException("error"), ), ): pyicloud_service._contacts = None with pytest.raises( PyiCloudServiceUnavailable, match="Contacts service not available", ): _: ContactsService = pyicloud_service.contacts def test_reminders_returns_service( pyicloud_service: PyiCloudService, ) -> None: """Test reminders property returns RemindersService instance.""" mock_reminders_service = MagicMock() with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://reminders.example.com", ), patch( "pyicloud.base.RemindersService", return_value=mock_reminders_service, ) as mock_reminders_cls, ): pyicloud_service._reminders = None result: RemindersService = pyicloud_service.reminders mock_reminders_cls.assert_called_once_with( service_root="https://reminders.example.com", session=pyicloud_service.session, params=pyicloud_service.params, ) assert result == mock_reminders_service def test_reminders_returns_cached_instance( pyicloud_service: PyiCloudService, ) -> None: """Test reminders property returns cached instance if already set.""" mock_reminders_service = MagicMock() pyicloud_service._reminders = mock_reminders_service result: RemindersService = pyicloud_service.reminders assert result == mock_reminders_service def test_reminders_raises_on_api_exception( pyicloud_service: PyiCloudService, ) -> None: """Test reminders property raises PyiCloudServiceUnavailable on API exception.""" with ( patch.object( pyicloud_service, "get_webservice_url", return_value="https://reminders.example.com", ), patch( "pyicloud.base.RemindersService", side_effect=PyiCloudAPIResponseException("error"), ), ): pyicloud_service._reminders = None with pytest.raises( PyiCloudServiceUnavailable, match="Reminders service not available", ): _ = pyicloud_service.reminders ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_cmdline.py0000644000175100017510000003231615133166711017211 0ustar00runnerrunner"""Cmdline tests.""" # pylint: disable=protected-access import argparse import pickle from io import BytesIO from pprint import pformat from unittest.mock import MagicMock, PropertyMock, mock_open, patch import pytest from pyicloud.cmdline import ( _create_parser, _display_device_message_option, _display_device_silent_message_option, _enable_lost_mode_option, _handle_2fa, _handle_2sa, _list_devices_option, _play_device_sound_option, create_pickled_data, main, ) from pyicloud.services.findmyiphone import AppleDevice from tests import PyiCloudSessionMock from tests.const import ( AUTHENTICATED_USER, FMI_FAMILY_WORKING, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_PASSWORD, ) def test_no_arg() -> None: """Test no args.""" with pytest.raises(SystemExit, match="2"): main() def test_username_password_invalid() -> None: """Test username and password commands.""" # No password supplied with ( patch("getpass.getpass", return_value=None), patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch("builtins.open", new_callable=mock_open), patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), pytest.raises(SystemExit, match="2"), ): mock_parse_args.return_value = argparse.Namespace( username="valid_user", password=None, debug=False, interactive=True, china_mainland=False, delete_from_keyring=False, loglevel="info", no_verify_ssl=False, http_proxy=None, https_proxy=None, session_dir="./", accept_terms=False, with_family=False, ) main() # 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", no_verify_ssl=True, http_proxy=None, https_proxy=None, session_dir="./", accept_terms=False, with_family=False, ) 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", no_verify_ssl=False, http_proxy="http://proxy:8080", https_proxy="https://proxy:8080", session_dir="./", accept_terms=True, with_family=True, ) 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", no_verify_ssl=True, http_proxy=None, https_proxy=None, session_dir="./", accept_terms=False, with_family=False, ) main() def test_device_outputfile(mock_file_open_write_fixture: MagicMock) -> None: """Test the outputfile command.""" with ( patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch("builtins.open", mock_file_open_write_fixture), patch("keyring.get_password", return_value=None), patch("pyicloud.base.PyiCloudSession", new=PyiCloudSessionMock), ): mock_parse_args.return_value = argparse.Namespace( username=AUTHENTICATED_USER, password=VALID_PASSWORD, debug=False, interactive=False, china_mainland=False, delete_from_keyring=False, device_id=None, locate=None, output_to_file=True, longlist=None, list=None, sound=None, message=None, silentmessage=None, lostmode=None, loglevel="none", no_verify_ssl=True, http_proxy=None, https_proxy=None, session_dir="./", accept_terms=False, with_family=False, ) main() devices = FMI_FAMILY_WORKING.get("content") if devices: for device in devices: file_name = device.get("name").strip().lower() + ".fmip_snapshot" assert file_name in mock_file_open_write_fixture.written_data buffer = BytesIO(mock_file_open_write_fixture.written_data[file_name]) contents = [] while True: try: contents.append(pickle.load(buffer)) except EOFError: break assert contents == [device] def test_create_pickled_data() -> None: """Test the creation of pickled data.""" idevice = MagicMock() idevice.data = {"key": "value"} filename = "test.pkl" 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.data, 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() location = PropertyMock(return_value="Test Location") type(dev).location = location # Call the function _list_devices_option(command_line, dev) # Verify that the location() method was called location.assert_called_once() def test_list_devices_option() -> None: """Test the list devices option.""" command_line = MagicMock( longlist=True, locate=False, output_to_file=False, list=False, ) content: dict[str, str] = { "name": "Test Device", "deviceDisplayName": "Test Display", "location": "Test Location", "batteryLevel": "100%", "batteryStatus": "Charging", "deviceClass": "Phone", "deviceModel": "iPhone", } dev = AppleDevice( content=content, params={}, manager=MagicMock(), sound_url="", lost_url="", message_url="", erase_token_url="", erase_url="", ) with patch("pyicloud.cmdline.create_pickled_data") as mock_create_pickled: _list_devices_option(command_line, dev) # Verify no pickled data creation mock_create_pickled.assert_not_called() # Check for proper console output during detailed listing with patch("builtins.print") as mock_print: _list_devices_option(command_line, dev) mock_print.assert_any_call("-" * 30) mock_print.assert_any_call("Test Device") for key, value in content.items(): mock_print.assert_any_call(f"{key:>30} - {pformat(value)}") def test_list_devices_option_short_list() -> None: """Test the list devices option with short list.""" # Create a mock command_line object with the list option enabled command_line = MagicMock( longlist=False, locate=False, output_to_file=False, list=True, # Enable the short list option ) # Create a mock device with sample content content: dict[str, str | list[dict[str, bool]]] = { "name": "Test Device", "deviceDisplayName": "Test Display", "location": "Test Location", "batteryLevel": "100%", "batteryStatus": "Charging", "deviceClass": "Phone", "deviceModel": "iPhone", "features": [ {"LOC": True}, ], } dev = AppleDevice( content=content, params={}, manager=MagicMock(), sound_url="", lost_url="", message_url="", erase_token_url="", erase_url="", ) with patch("builtins.print") as mock_print: # Call the function _list_devices_option(command_line, dev) # Verify the output for short list option mock_print.assert_any_call("-" * 30) mock_print.assert_any_call("Name - Test Device") mock_print.assert_any_call("Display Name - Test Display") mock_print.assert_any_call("Location - Test Location") mock_print.assert_any_call("Battery Level - 100%") mock_print.assert_any_call("Battery Status - Charging") mock_print.assert_any_call("Device Class - Phone") mock_print.assert_any_call("Device Model - iPhone") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_cookie_jar.py0000644000175100017510000000700615133166711017701 0ustar00runnerrunner"""Tests for the PyiCloudCookieJar class and its handling of FMIP auth cookies.""" from io import StringIO from unittest.mock import MagicMock, mock_open, patch from pyicloud.cookie_jar import _FMIP_AUTH_COOKIE_NAME, PyiCloudCookieJar def create_cookie_jar_with_cookie( filename, name, domain="example.com", path="/", value="test" ) -> PyiCloudCookieJar: """Create a PyiCloudCookieJar with a single cookie.""" with ( patch("builtins.open", new_callable=mock_open), patch("os.open", new_callable=mock_open), ): jar = PyiCloudCookieJar(filename=filename) jar.set(name, value, domain=domain, path=path) return jar def test_load_no_filename() -> None: """Test that load is a no-op if no filename is set.""" jar = PyiCloudCookieJar() # Should not raise or do anything if no filename is set # with patch("builtins.open", mock_open()): jar.load() # No-op def test_load_with_filename_removes_fmip_cookie() -> None: """Test that loading a jar with an FMIP cookie removes that cookie.""" filename = "test_cookies.txt" buffer = StringIO() buffer.close = MagicMock() with ( patch("builtins.open", new_callable=mock_open) as m, patch("os.open"), patch("os.fdopen") as os_fdopen, ): m.return_value = buffer os_fdopen.return_value = buffer jar: PyiCloudCookieJar = create_cookie_jar_with_cookie( filename, _FMIP_AUTH_COOKIE_NAME ) # Add a non-FMIP cookie too jar.set("other_cookie", "value", domain="example.com", path="/") jar.save() # Reload and check FMIP cookie is removed jar2 = PyiCloudCookieJar(filename=filename) buffer.seek(0) jar2.load() names: list[str] = [cookie.name for cookie in jar2] assert _FMIP_AUTH_COOKIE_NAME not in names assert "other_cookie" in names def test_load_with_custom_filename_argument_removes_fmip_cookie() -> None: """Test that loading a jar with an FMIP cookie removes that cookie.""" filename = "test_cookies.txt" buffer = StringIO() buffer.close = MagicMock() with ( patch("builtins.open", new_callable=mock_open) as m, patch("os.open"), patch("os.fdopen") as os_fdopen, ): m.return_value = buffer os_fdopen.return_value = buffer jar: PyiCloudCookieJar = create_cookie_jar_with_cookie( filename, _FMIP_AUTH_COOKIE_NAME ) jar.save() jar2: PyiCloudCookieJar = PyiCloudCookieJar() names: list[str] = [cookie.name for cookie in jar2] assert _FMIP_AUTH_COOKIE_NAME not in names def test_load_handles_keyerror_on_clear() -> None: """Test that load handles KeyError from clear gracefully.""" filename = "test_cookies.txt" buffer = StringIO() buffer.close = MagicMock() with ( patch("builtins.open", new_callable=mock_open) as m, patch("os.open"), patch("os.fdopen") as os_fdopen, ): m.return_value = buffer os_fdopen.return_value = buffer jar: PyiCloudCookieJar = create_cookie_jar_with_cookie( filename, _FMIP_AUTH_COOKIE_NAME ) jar.save() buffer.seek(0) jar2: PyiCloudCookieJar = PyiCloudCookieJar(filename=filename) # Monkeypatch clear to raise KeyError def raise_keyerror(*args, **kwargs) -> None: raise KeyError with patch.object(jar2, "clear", side_effect=raise_keyerror): # Should not raise jar2.load() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_srp_password.py0000644000175100017510000000621615133166711020324 0ustar00runnerrunner"""Tests for the SrpPassword class in pyicloud.srp_password module.""" from hashlib import sha256 from unittest.mock import patch import pytest from pyicloud.srp_password import SrpPassword, SrpProtocolType def test_encode_raises_value_error_if_encrypt_info_not_set() -> None: """Test that encode raises ValueError if encrypt info is not set.""" srp = SrpPassword("testpassword") with pytest.raises(ValueError, match="Encrypt info not set"): srp.encode() def test_encode_raises_value_error_if_protocol_not_valid() -> None: """Test that encode raises ValueError if protocol is not valid.""" srp = SrpPassword("testpassword") srp.set_encrypt_info("abc", "1", "32", None) # type: ignore with pytest.raises(ValueError, match="Unsupported SrpPassword type"): srp.encode() @pytest.mark.parametrize( "password,salt,iterations,key_length,expected_length,protocol", [ ("password123", b"salty", 1000, 32, 32, SrpProtocolType.S2K), ("anotherpass", b"12345678", 500, 16, 16, SrpProtocolType.S2K), ("anotherpass", b"12345678", 500, 16, 16, SrpProtocolType.S2K_FO), ], ) def test_encode_returns_correct_length( password: str, salt: bytes, iterations: int, key_length: int, expected_length: int, protocol: SrpProtocolType, ) -> None: """Test that encode returns bytes of the expected length.""" srp = SrpPassword(password) srp.set_encrypt_info(salt, iterations, key_length, protocol) result: bytes = srp.encode() assert isinstance(result, bytes) assert len(result) == expected_length def test_encode_consistency_for_same_input() -> None: """Test that encode returns the same result for the same input.""" srp1 = SrpPassword("mypassword") srp2 = SrpPassword("mypassword") salt = b"abcdef" iterations = 1000 key_length = 24 srp1.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K) srp2.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K) assert srp1.encode() == srp2.encode() srp1.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K_FO) srp2.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K_FO) assert srp1.encode() == srp2.encode() def test_srp_password_digest() -> None: """Test that the SrpPassword digest method works as expected.""" password = "securepassword" salt = b"saltysalt" iterations = 2000 key_length = 32 password_digest: bytes = sha256(password.encode("utf-8")).digest() password_digest_hex: bytes = sha256(password.encode("utf-8")).hexdigest().encode() with patch("pyicloud.srp_password.pbkdf2_hmac") as mock_pbkdf2_hmac: srp = SrpPassword(password) srp.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K) _ = srp.encode() mock_pbkdf2_hmac.assert_called_with( "sha256", password_digest, salt, iterations, key_length ) srp.set_encrypt_info(salt, iterations, key_length, SrpProtocolType.S2K_FO) _ = srp.encode() mock_pbkdf2_hmac.assert_called_with( "sha256", password_digest_hex, salt, iterations, key_length ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_ssl_context.py0000644000175100017510000000661615133166711020147 0ustar00runnerrunner"""Tests for the SSL context configuration in pyicloud.ssl_context.""" import warnings from typing import Any import requests from pytest import MonkeyPatch from urllib3.exceptions import InsecureRequestWarning from pyicloud.ssl_context import configurable_ssl_verification def test_ssl_verification_true(monkeypatch: MonkeyPatch) -> None: """Test that SSL verification is enabled by default.""" called: dict[str, Any] = {} def fake_merge(self, url, proxies, stream, verify, cert) -> dict[str, Any]: # pylint: disable=unused-argument called["verify"] = verify called["proxies"] = proxies return {"verify": verify, "proxies": proxies} monkeypatch.setattr(requests.Session, "merge_environment_settings", fake_merge) with configurable_ssl_verification(): session = requests.Session() session.merge_environment_settings("https://example.com", {}, False, True, None) assert called["verify"] is True assert called["proxies"] == {} def test_ssl_verification_false(monkeypatch: MonkeyPatch) -> None: """Test that SSL verification is disabled when verify_ssl=False.""" called: dict[str, Any] = {} def fake_merge(self, url, proxies, stream, verify, cert) -> dict[str, Any]: # pylint: disable=unused-argument called["verify"] = verify called["proxies"] = proxies return {"verify": verify, "proxies": proxies} monkeypatch.setattr(requests.Session, "merge_environment_settings", fake_merge) with configurable_ssl_verification(verify_ssl=False): session = requests.Session() result = session.merge_environment_settings( "https://example.com", {}, False, True, None ) assert result["verify"] is False assert result["proxies"] == {} def test_proxy_settings(monkeypatch: MonkeyPatch) -> None: """Test that proxy settings are applied correctly.""" called: dict[str, Any] = {} def fake_merge(self, url, proxies, stream, verify, cert) -> dict[str, Any]: # pylint: disable=unused-argument called["verify"] = verify called["proxies"] = proxies return {"verify": verify, "proxies": proxies} monkeypatch.setattr(requests.Session, "merge_environment_settings", fake_merge) with configurable_ssl_verification( http_proxy="http://proxy", https_proxy="https://proxy" ): session = requests.Session() result = session.merge_environment_settings( "https://example.com", {}, False, True, None ) assert result["proxies"] == {"http": "http://proxy", "https": "https://proxy"} def test_insecure_request_warning(monkeypatch: MonkeyPatch) -> None: """Test that InsecureRequestWarning is suppressed when verify_ssl=False.""" warnings.simplefilter("always") monkeypatch.setattr( requests.Session, "merge_environment_settings", lambda *a, **kw: {} ) with configurable_ssl_verification(verify_ssl=False): with warnings.catch_warnings(record=True) as w: warnings.warn("test", InsecureRequestWarning) # InsecureRequestWarning should be suppressed insecure_warnings: list[warnings.WarningMessage] = [ warning for warning in w if issubclass(warning.category, InsecureRequestWarning) ] assert len(insecure_warnings) == 0, ( "InsecureRequestWarning should be suppressed" ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768746441.0 pyicloud-2.3.0/tests/test_utils.py0000644000175100017510000000155115133166711016733 0ustar00runnerrunner""" Tests for the utils module. """ import pytest from pyicloud.utils import camelcase_to_underscore @pytest.mark.parametrize( "camel_str,expected", [ ("startDate", "start_date"), ("localStartDate", "local_start_date"), ("hasAttachments", "has_attachments"), ("simple", "simple"), ("CamelCase", "camel_case"), ("already_snake_case", "already_snake_case"), ("", ""), ("A", "a"), ("TestABC", "test_a_b_c"), ("testABC", "test_a_b_c"), ("testA", "test_a"), ("TestA", "test_a"), ("test", "test"), ("Test", "test"), ("testID", "test_i_d"), ("IDTest", "i_d_test"), ], ) def test_camelcase_to_underscore(camel_str, expected): """Test the camelcase_to_underscore function.""" assert camelcase_to_underscore(camel_str) == expected