├── .flake8 ├── MANIFEST.in ├── Coveragerc ├── requirements.txt ├── .vscode ├── settings.json └── launch.json ├── icloudpy ├── __init__.py ├── services │ ├── __init__.py │ ├── contacts.py │ ├── calendar.py │ ├── reminders.py │ ├── findmyiphone.py │ ├── account.py │ └── drive.py ├── exceptions.py ├── utils.py ├── cmdline.py └── base.py ├── requirements-test.txt ├── tests ├── bandit.yaml ├── const.py ├── const_auth.py ├── const_drive_upload.py ├── test_utils.py ├── const_account_family.py ├── test_findmyiphone.py ├── test_cmdline.py ├── test_account.py ├── const_account.py ├── const_login.py └── __init__.py ├── pytest.ini ├── run-in-env.sh ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml ├── workflows │ ├── pypi-publish.yml │ ├── coverage-badge.yml │ ├── ci-pr-test.yml │ └── ci-main-test-coverage.yml └── copilot-instructions.md ├── run-ci.sh ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── setup.py ├── .yamllint ├── .gitignore ├── LICENSE ├── .ruff.toml ├── generate_badges.py ├── .pre-commit-config.yaml ├── README.md └── pylintrc /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /Coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = 3 | icloudpy/* 4 | [report] 5 | include = 6 | icloudpy/* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.5 2 | keyring==25.7.0 3 | keyrings.alt==5.0.2 4 | click==8.1.8 5 | srp==1.0.22 6 | tzlocal==5.2 7 | pytz==2024.2 8 | certifi==2025.11.12 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /icloudpy/__init__.py: -------------------------------------------------------------------------------- 1 | """The iCloudPy library.""" 2 | 3 | import logging 4 | 5 | from icloudpy.base import ICloudPyService # # noqa: F401 6 | 7 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 8 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest==8.4.2 3 | allure-pytest==2.15.2 4 | coverage==7.10.7 5 | pytest-cov==7.0.0 6 | black==25.11.0 7 | ruff==0.14.8 8 | ipython==8.37.0 9 | pre-commit==4.3.0 10 | setuptools==80.9.0 11 | build==1.3.0 -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B103 5 | - B108 6 | - B306 7 | - B307 8 | - B313 9 | - B314 10 | - B315 11 | - B316 12 | - B317 13 | - B318 14 | - B319 15 | - B320 16 | - B601 17 | - B602 18 | - B604 19 | - B608 20 | - B609 21 | -------------------------------------------------------------------------------- /icloudpy/services/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | """Services.""" 3 | 4 | from icloudpy.services.account import AccountService 5 | from icloudpy.services.calendar import CalendarService 6 | from icloudpy.services.contacts import ContactsService 7 | from icloudpy.services.drive import DriveService 8 | from icloudpy.services.findmyiphone import ( 9 | FindMyiPhoneServiceManager, 10 | ) 11 | from icloudpy.services.photos import PhotosService 12 | from icloudpy.services.reminders import ( 13 | RemindersService, 14 | ) 15 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = 7 | --verbose 8 | --cov=icloudpy 9 | --cov-report=term-missing 10 | --cov-report=html:htmlcov 11 | --cov-report=xml:coverage.xml 12 | --cov-fail-under=78 13 | --strict-markers 14 | --tb=short 15 | --alluredir=./allure-results 16 | --cov-config=Coveragerc 17 | markers = 18 | unit: Unit tests 19 | integration: Integration tests 20 | slow: Slow-running tests -------------------------------------------------------------------------------- /run-in-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | # Activate pyenv and virtualenv if present, then run the specified command 5 | 6 | # pyenv, pyenv-virtualenv 7 | if [ -s .python-version ]; then 8 | PYENV_VERSION=$(head -n 1 .python-version) 9 | export PYENV_VERSION 10 | fi 11 | 12 | # other common virtualenvs 13 | my_path=$(git rev-parse --show-toplevel) 14 | 15 | for venv in venv .venv .; do 16 | if [ -f "${my_path}/${venv}/bin/activate" ]; then 17 | . "${my_path}/${venv}/bin/activate" 18 | break 19 | fi 20 | done 21 | 22 | exec "$@" -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Test constants.""" 2 | from .const_account_family import APPLE_ID_EMAIL, ICLOUD_ID_EMAIL, PRIMARY_EMAIL 3 | 4 | # Base 5 | AUTHENTICATED_USER = PRIMARY_EMAIL 6 | REQUIRES_2FA_TOKEN = "requires_2fa_token" 7 | REQUIRES_2FA_USER = "requires_2fa_user" 8 | VALID_USERS = [AUTHENTICATED_USER, REQUIRES_2FA_USER, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL] 9 | VALID_PASSWORD = "valid_password" 10 | VALID_COOKIE = "valid_cookie" 11 | VALID_TOKEN = "valid_token" 12 | VALID_2FA_CODE = "000000" 13 | VALID_TOKENS = [VALID_TOKEN, REQUIRES_2FA_TOKEN] 14 | 15 | CLIENT_ID = "client_id" 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: mandarons 7 | --- 8 | 9 | **Use case** 10 | As a , I want to so that I can . 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /run-ci.sh: -------------------------------------------------------------------------------- 1 | deleteDir() { 2 | if [ -d $1 ]; then rm -rf $1; fi 3 | } 4 | deleteFile() { 5 | if [ -f $1 ]; then rm -f $1; fi 6 | } 7 | echo "Cleaning ..." 8 | deleteDir .pytest_cache 9 | deleteDir allure-results 10 | deleteDir allure-report 11 | deleteDir htmlcov 12 | deleteDir build 13 | deleteDir dist 14 | deleteDir icloudpy.egg-info 15 | deleteFile .coverage 16 | deleteFile coverage.xml 17 | 18 | echo "Ruffing ..." && 19 | ruff check --fix && 20 | echo "Testing ..." && 21 | pytest && 22 | echo "Reporting ..." && 23 | allure generate --clean && 24 | echo "Building the distribution ..." && 25 | python -m build && 26 | echo "Done." 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: mandarons 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Do this '...' 16 | 2. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Configuration** 25 | If applicable, please share the configuration details 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Attach using Process Id", 9 | "type": "debugpy", 10 | "request": "attach", 11 | "processId": "${command:pickProcess}" 12 | }, 13 | { 14 | "name": "Debug Tests", 15 | "type": "python", 16 | "request": "test", 17 | "console": "integratedTerminal", 18 | "justMyCode": false, 19 | "env": {"PYTEST_ADDOPTS": "--no-cov"} 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://www.buymeacoffee.com/mandarons"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:0-3.10 2 | 3 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 4 | 5 | RUN \ 6 | apt-get update \ 7 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 8 | software-properties-common git default-jre && \ 9 | apt-get clean &&\ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | RUN \ 13 | wget https://repo.maven.apache.org/maven2/io/qameta/allure/allure-commandline/2.20.1/allure-commandline-2.20.1.zip && \ 14 | unzip allure-commandline-2.20.1.zip -d /allure && \ 15 | rm allure-commandline-2.20.1.zip 16 | 17 | USER vscode 18 | # Install uv (pip replacement) 19 | RUN \ 20 | curl -LsSf https://astral.sh/uv/install.sh | sh 21 | 22 | ENV PATH="/allure/allure-2.20.1/bin:/home/vscode/.cargo/bin:${PATH}" 23 | 24 | WORKDIR /workspaces 25 | 26 | # Set the default shell to bash instead of sh 27 | ENV SHELL /bin/bash -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: | 29 | python setup.py sdist bdist_wheel 30 | twine upload dist/* 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | 3 | from setuptools import find_packages, setup 4 | 5 | REPO_URL = "https://github.com/mandarons/icloudpy" 6 | VERSION = "0.8.0" 7 | 8 | with open("README.md") as fh: 9 | long_description = fh.read() 10 | with open("requirements.txt") as fh: 11 | required = fh.read().splitlines() 12 | 13 | setup( 14 | name="icloudpy", 15 | version=VERSION, 16 | author="Mandar Patil", 17 | author_email="mandarons@pm.me", 18 | description="Python library to interact with iCloud web service", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | url=REPO_URL, 22 | package_dir={".": ""}, 23 | packages=find_packages(exclude=["tests"]), 24 | classifiers=[ 25 | "Programming Language :: Python :: 3", 26 | "Operating System :: OS Independent", 27 | ], 28 | python_requires=">=3.8", 29 | install_requires=required, 30 | entry_points=""" 31 | [console_scripts] 32 | icloud=icloudpy.cmdline:main 33 | """, 34 | ) 35 | -------------------------------------------------------------------------------- /.github/workflows/coverage-badge.yml: -------------------------------------------------------------------------------- 1 | name: Update Coverage Badge 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'icloudpy/**' 8 | - 'tests/**' 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | badge: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.10' 24 | 25 | - name: Install dependencies 26 | run: | 27 | pip install -r requirements.txt 28 | pip install -r requirements-test.txt 29 | 30 | - name: Run tests with coverage 31 | run: | 32 | pytest 33 | 34 | - name: Generate badge 35 | run: | 36 | python generate_badges.py 37 | 38 | - name: Commit badge 39 | run: | 40 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 41 | git config --local user.name "github-actions[bot]" 42 | git add coverage-badge.json 43 | git diff --quiet && git diff --staged --quiet || git commit -m "Update coverage badge [skip ci]" 44 | git push 45 | -------------------------------------------------------------------------------- /icloudpy/exceptions.py: -------------------------------------------------------------------------------- 1 | """Library exceptions.""" 2 | 3 | 4 | class ICloudPyException(Exception): 5 | """Generic iCloud exception.""" 6 | 7 | 8 | # API 9 | class ICloudPyAPIResponseException(ICloudPyException): 10 | """iCloud response exception.""" 11 | 12 | def __init__(self, reason, code=None, retry=False): 13 | self.reason = reason 14 | self.code = code 15 | message = reason or "" 16 | if code: 17 | message += f" ({code})" 18 | if retry: 19 | message += ". Retrying ..." 20 | 21 | super().__init__(message) 22 | 23 | 24 | class ICloudPyServiceNotActivatedException(ICloudPyAPIResponseException): 25 | """iCloud service not activated exception.""" 26 | 27 | 28 | # Login 29 | class ICloudPyFailedLoginException(ICloudPyException): 30 | """iCloud failed login exception.""" 31 | 32 | 33 | class ICloudPy2SARequiredException(ICloudPyException): 34 | """iCloud 2SA required exception.""" 35 | 36 | def __init__(self, apple_id): 37 | message = f"Two-step authentication required for account:{apple_id}" 38 | super().__init__(message) 39 | 40 | 41 | class ICloudPyNoStoredPasswordAvailableException(ICloudPyException): 42 | """iCloud no stored password exception.""" 43 | 44 | 45 | # Webservice specific 46 | class ICloudPyNoDevicesException(ICloudPyException): 47 | """iCloud no device exception.""" 48 | -------------------------------------------------------------------------------- /tests/const_auth.py: -------------------------------------------------------------------------------- 1 | """Authentication test constants. 2 | 3 | This module contains fixtures for authentication flows including: 4 | - SRP authentication (init and complete) 5 | - 2FA validation 6 | - Trust token management 7 | - Session validation 8 | """ 9 | 10 | # SRP Authentication 11 | SRP_INIT_OK = { 12 | "iteration": 20433, 13 | "salt": "0samK84bcBmkVsswOpZbZg==", 14 | "protocol": "s2k", 15 | "b": ( 16 | "STVHcWTN9YOYn4IgtIJ6UPdPbvzvL+zza/l+6yUHUtdEyxwzpB78y8wqZ8QWSbVqjBcpl32iEA4T3nYp0LWZ5hD3r3yIJFloXvX0kpBJkr" 17 | "+Nh8EfHuW1V50A8riH6VWyuJ8m3JmOO7/xkNgP7je8GMpt/5f/7qE3AOj73e3JR0fzQ7IopdU0tlyVX0tD7T6wCyHS52GJWDdq1I2bgzurIK2" 18 | "/ZjR/Hwzd/67oFQPtKQgjrSRaKo5MJEfDP7C9wOlXsZqbb7igX6PeZRWrfl+iQFaA/FVeWSngB07ja3wOryY9GsYO06ELGOaQ+MpsT7mouqrGT" 19 | "fOJ0OMh9EgrkJEM6w==" 20 | ), 21 | "c": "e-1be-8746c235-b41c-11ef-bd17-c780acb4fe15:PRN", 22 | } 23 | 24 | # Authentication response 25 | AUTH_OK = {"authType": "hsa2"} 26 | 27 | # 2FA Trusted Devices 28 | TRUSTED_DEVICE_1 = { 29 | "deviceType": "SMS", 30 | "areaCode": "", 31 | "phoneNumber": "*******58", 32 | "deviceId": "1", 33 | } 34 | TRUSTED_DEVICES = {"devices": [TRUSTED_DEVICE_1]} 35 | 36 | # Verification Code Responses 37 | VERIFICATION_CODE_OK = {"success": True} 38 | VERIFICATION_CODE_KO = {"success": False} 39 | 40 | # Trust Token Response (empty 204 response) 41 | TRUST_TOKEN_OK = "" 42 | 43 | # Session Validation Response (empty 204 response) 44 | SESSION_VALID = "" 45 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | ignore: | 2 | azure-*.yml 3 | rules: 4 | braces: 5 | level: error 6 | min-spaces-inside: 0 7 | max-spaces-inside: 1 8 | min-spaces-inside-empty: -1 9 | max-spaces-inside-empty: -1 10 | brackets: 11 | level: error 12 | min-spaces-inside: 0 13 | max-spaces-inside: 0 14 | min-spaces-inside-empty: -1 15 | max-spaces-inside-empty: -1 16 | colons: 17 | level: error 18 | max-spaces-before: 0 19 | max-spaces-after: 1 20 | commas: 21 | level: error 22 | max-spaces-before: 0 23 | min-spaces-after: 1 24 | max-spaces-after: 1 25 | comments: 26 | level: error 27 | require-starting-space: true 28 | min-spaces-from-content: 1 29 | comments-indentation: 30 | level: error 31 | document-end: 32 | level: error 33 | present: false 34 | document-start: 35 | level: error 36 | present: false 37 | empty-lines: 38 | level: error 39 | max: 1 40 | max-start: 0 41 | max-end: 1 42 | hyphens: 43 | level: error 44 | max-spaces-after: 1 45 | indentation: 46 | level: error 47 | spaces: 2 48 | indent-sequences: true 49 | check-multi-line-strings: false 50 | key-duplicates: 51 | level: error 52 | line-length: disable 53 | new-line-at-end-of-file: 54 | level: error 55 | new-lines: 56 | level: error 57 | type: unix 58 | trailing-spaces: 59 | level: error 60 | truthy: 61 | level: error 62 | allowed-values: 63 | - "on" 64 | - "true" 65 | - "false" 66 | -------------------------------------------------------------------------------- /icloudpy/services/contacts.py: -------------------------------------------------------------------------------- 1 | """Contacts service.""" 2 | 3 | 4 | class ContactsService: 5 | """ 6 | The 'Contacts' iCloud service, connects to iCloud and returns contacts. 7 | """ 8 | 9 | def __init__(self, service_root, session, params): 10 | self.session = session 11 | self.params = params 12 | self._service_root = service_root 13 | self._contacts_endpoint = f"{self._service_root}/co" 14 | self._contacts_refresh_url = f"{self._contacts_endpoint}/startup" 15 | self._contacts_next_url = f"{self._contacts_endpoint}/contacts" 16 | self._contacts_changeset_url = f"{self._contacts_endpoint}/changeset" 17 | 18 | self.response = {} 19 | 20 | def refresh_client(self): 21 | """ 22 | Refreshes the ContactsService endpoint, ensuring that the 23 | contacts data is up-to-date. 24 | """ 25 | params_contacts = dict(self.params) 26 | params_contacts.update( 27 | { 28 | "clientVersion": "2.1", 29 | "locale": "en_US", 30 | "order": "last,first", 31 | }, 32 | ) 33 | req = self.session.get(self._contacts_refresh_url, params=params_contacts) 34 | self.response = req.json() 35 | 36 | params_next = dict(params_contacts) 37 | params_next.update( 38 | { 39 | "prefToken": self.response["prefToken"], 40 | "syncToken": self.response["syncToken"], 41 | "limit": "0", 42 | "offset": "0", 43 | }, 44 | ) 45 | req = self.session.get(self._contacts_next_url, params=params_next) 46 | self.response = req.json() 47 | 48 | def all(self): 49 | """ 50 | Retrieves all contacts. 51 | """ 52 | self.refresh_client() 53 | return self.response.get("contacts") 54 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iCloudPy Dev", 3 | "context": "..", 4 | "dockerFile": "Dockerfile", 5 | "containerEnv": { "DEVCONTAINER": "1" }, 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "ms-python.vscode-pylance", 10 | "visualstudioexptteam.vscodeintellicode", 11 | "redhat.vscode-yaml", 12 | "esbenp.prettier-vscode", 13 | "GitHub.vscode-pull-request-github", 14 | "github.vscode-github-actions", 15 | "charliermarsh.ruff" 16 | ], 17 | // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json 18 | "settings": { 19 | "[python]": { 20 | "diffEditor.ignoreTrimWhitespace": false, 21 | "editor.formatOnType": true, 22 | "editor.formatOnSave": true, 23 | "editor.wordBasedSuggestions": "off", 24 | "editor.defaultFormatter": "charliermarsh.ruff", 25 | "editor.codeActionsOnSave": { 26 | "source.fixAll": "explicit", 27 | "source.organizeImports": "explicit" 28 | } 29 | }, 30 | "python.pythonPath": "./venv/bin/python", 31 | "python.testing.pytestArgs": ["--no-cov"], 32 | "files.trimTrailingWhitespace": true, 33 | "terminal.integrated.defaultProfile.linux": "bash", 34 | "yaml.customTags": [ 35 | "!input scalar", 36 | "!secret scalar", 37 | "!include_dir_named scalar", 38 | "!include_dir_list scalar", 39 | "!include_dir_merge_list scalar", 40 | "!include_dir_merge_named scalar" 41 | ] 42 | } 43 | } 44 | }, 45 | "remoteUser": "vscode", 46 | "postCreateCommand": "uv venv && . .venv/bin/activate && uv pip install -r requirements-test.txt && git config commit.gpgsign true", 47 | "mounts": [ 48 | "source=${localEnv:HOME}${localEnv:USERPROFILE}/.gnupg,target=/home/vscode/.gnupg,type=bind,consistency=cached" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /icloudpy/utils.py: -------------------------------------------------------------------------------- 1 | """Utils.""" 2 | import getpass 3 | from sys import stdout 4 | 5 | import keyring 6 | 7 | from .exceptions import ICloudPyNoStoredPasswordAvailableException 8 | 9 | KEYRING_SYSTEM = "icloudpy://icloud-password" 10 | 11 | 12 | def get_password(username, interactive=stdout.isatty() if stdout else False): 13 | """Get the password from a username.""" 14 | try: 15 | return get_password_from_keyring(username) 16 | except ICloudPyNoStoredPasswordAvailableException: 17 | if not interactive: 18 | raise 19 | 20 | return getpass.getpass(f"Enter iCloud password for {username}: ") 21 | 22 | 23 | def password_exists_in_keyring(username): 24 | """Return true if the password of a username exists in the keyring.""" 25 | try: 26 | get_password_from_keyring(username) 27 | except ICloudPyNoStoredPasswordAvailableException: 28 | return False 29 | 30 | return True 31 | 32 | 33 | def get_password_from_keyring(username): 34 | """Get the password from a username.""" 35 | result = keyring.get_password(KEYRING_SYSTEM, username) 36 | if result is None: 37 | raise ICloudPyNoStoredPasswordAvailableException( 38 | f"No iCloudPy password for {username} could be found " 39 | "in the system keychain. Use the `--store-in-keyring` " 40 | "command-line option for storing a password for this " 41 | "username.", 42 | ) 43 | 44 | return result 45 | 46 | 47 | def store_password_in_keyring(username, password): 48 | """Store the password of a username.""" 49 | return keyring.set_password(KEYRING_SYSTEM, username, password) 50 | 51 | 52 | def delete_password_in_keyring(username): 53 | """Delete the password of a username.""" 54 | return keyring.delete_password(KEYRING_SYSTEM, username) 55 | 56 | 57 | def underscore_to_camelcase(word, initial_capital=False): 58 | """Transform a word to camelCase.""" 59 | words = [x.capitalize() or "_" for x in word.split("_")] 60 | if not initial_capital: 61 | words[0] = words[0].lower() 62 | 63 | return "".join(words) 64 | -------------------------------------------------------------------------------- /tests/const_drive_upload.py: -------------------------------------------------------------------------------- 1 | """Drive upload and file operation test constants. 2 | 3 | This module contains fixtures for drive file operations including: 4 | - File upload URL responses 5 | - Content update responses 6 | - Folder creation responses 7 | - Item rename responses 8 | - Move to trash responses 9 | """ 10 | 11 | # Upload URL response - returned by /upload/web endpoint 12 | UPLOAD_URL_RESPONSE = [ 13 | { 14 | "document_id": "NEW-DOCUMENT-ID-123", 15 | "url": "https://p31-docws.icloud.com:443/ws/upload/file", 16 | "owner": "OWNER_ID", 17 | "zone": "com.apple.CloudDocs", 18 | }, 19 | ] 20 | 21 | # Content update response - returned by /update/documents endpoint 22 | UPDATE_CONTENTWS_RESPONSE = { 23 | "results": [ 24 | { 25 | "document_id": "NEW-DOCUMENT-ID-123", 26 | "drivewsid": "DOCUMENT::com.apple.CloudDocs::NEW-DOCUMENT-ID-123", 27 | "etag": "ETAG_VALUE_123", 28 | "zone": "com.apple.CloudDocs", 29 | }, 30 | ], 31 | } 32 | 33 | # Rename items response - returned by /renameItems endpoint 34 | RENAME_ITEMS_RESPONSE = { 35 | "items": [ 36 | { 37 | "drivewsid": "DOCUMENT::com.apple.CloudDocs::516C896C-6AA5-4A30-B30E-5502C2333DAE", 38 | "docwsid": "516C896C-6AA5-4A30-B30E-5502C2333DAE", 39 | "zone": "com.apple.CloudDocs", 40 | "name": "new_name.pdf", 41 | "parentId": "D5AA0425-E84F-4501-AF5D-60F1D92648CF", 42 | "etag": "ETAG_VALUE_RENAMED", 43 | "type": "FILE", 44 | "assetQuota": 0, 45 | "fileCount": 0, 46 | "shareCount": 0, 47 | "shareAliasCount": 0, 48 | "directChildrenCount": 0, 49 | }, 50 | ], 51 | } 52 | 53 | # Move to trash response - returned by /moveItemsToTrash endpoint 54 | TRASH_ITEMS_RESPONSE = { 55 | "items": [ 56 | { 57 | "drivewsid": "DOCUMENT::com.apple.CloudDocs::516C896C-6AA5-4A30-B30E-5502C2333DAE", 58 | "docwsid": "516C896C-6AA5-4A30-B30E-5502C2333DAE", 59 | "zone": "com.apple.CloudDocs", 60 | "etag": "ETAG_VALUE_TRASHED", 61 | "status": "OK", 62 | }, 63 | ], 64 | } 65 | 66 | # Folder creation response - returned by /createFolders endpoint 67 | # (This is already handled in DRIVE_SUBFOLDER_WORKING_AFTER_MKDIR in const_drive.py) 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Other 132 | src/drive 133 | allure-results 134 | allure-report 135 | .history 136 | ignore-config.yaml 137 | gh-pages 138 | *.pub 139 | session_data/ 140 | session_data_original/ 141 | -------------------------------------------------------------------------------- /.github/workflows/ci-pr-test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths: 7 | - 'icloudpy/**' 8 | - 'tests/**' 9 | - 'pytest.ini' 10 | - 'requirements*.txt' 11 | - '.github/workflows/ci-pr-test.yml' 12 | - 'run-ci.sh' 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | cache-requirements-install: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout source code 23 | uses: actions/checkout@v4 24 | - name: Set up Python 3.10 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.10" 28 | - name: Cache pip dependencies 29 | uses: actions/cache@v4 30 | id: cache-dependencies 31 | with: 32 | path: ~/.cache/pip 33 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | - name: Install dependencies 37 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 38 | run: | 39 | pip install -r requirements-test.txt 40 | 41 | test: 42 | needs: cache-requirements-install 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout source code 46 | uses: actions/checkout@v4 47 | - name: Set up Python 3.10 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: "3.10" 51 | - name: Restore pip cache dependencies 52 | uses: actions/cache@v4 53 | with: 54 | path: ~/.cache/pip 55 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} 56 | restore-keys: | 57 | ${{ runner.os }}-pip- 58 | - name: Install dependencies 59 | run: | 60 | pip install -r requirements-test.txt 61 | # - name: Setup tmate session 62 | # uses: mxschmitt/action-tmate@v3 63 | - name: Test with pytest 64 | run: | 65 | ruff check && pytest 66 | - name: Generate Allure Report 67 | uses: simple-elf/allure-report-action@master 68 | if: always() 69 | with: 70 | allure_results: allure-results 71 | allure_history: allure-history 72 | keep_reports: 100 73 | - name: Upload tests artifacts 74 | if: ${{ failure() }} 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: tests-output 78 | path: allure-history 79 | retention-days: 1 80 | - name: Upload coverage artifacts 81 | uses: actions/upload-artifact@v4 82 | if: ${{ success() }} 83 | with: 84 | name: coverage-output 85 | path: htmlcov 86 | retention-days: 1 87 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Mandar Patil (mandarons@pm.me) 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | This library incorporates work of pyicloud library licensed as below: 32 | 33 | The MIT License (MIT) 34 | 35 | Copyright (c) 2016 The PyiCloud Authors 36 | 37 | Permission is hereby granted, free of charge, to any person obtaining a copy 38 | of this software and associated documentation files (the "Software"), to deal 39 | in the Software without restriction, including without limitation the rights 40 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 41 | copies of the Software, and to permit persons to whom the Software is 42 | furnished to do so, subject to the following conditions: 43 | 44 | The above copyright notice and this permission notice shall be included in all 45 | copies or substantial portions of the Software. 46 | 47 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 48 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 49 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 50 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 51 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 52 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 53 | SOFTWARE. 54 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = [ 2 | ".bzr", 3 | ".direnv", 4 | ".eggs", 5 | ".git", 6 | ".git-rewrite", 7 | ".hg", 8 | ".ipynb_checkpoints", 9 | ".mypy_cache", 10 | ".nox", 11 | ".pants.d", 12 | ".pyenv", 13 | ".pytest_cache", 14 | ".pytype", 15 | ".ruff_cache", 16 | ".svn", 17 | ".tox", 18 | ".venv", 19 | ".vscode", 20 | "__pypackages__", 21 | "_build", 22 | "buck-out", 23 | "build", 24 | "dist", 25 | "node_modules", 26 | "site-packages", 27 | "venv", 28 | ] 29 | 30 | # Same as Black. 31 | line-length = 120 32 | indent-width = 4 33 | 34 | # Assume Python 3.8 35 | # target-version = "py38" 36 | 37 | [lint] 38 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 39 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 40 | # McCabe complexity (`C901`) by default. 41 | select = ["F", "E", "W", 42 | # "C90", 43 | "I", 44 | # "N", 45 | # "D", 46 | "UP", "YTT", 47 | # "ANN", 48 | "ASYNC", 49 | # "S", 50 | # "BLE", 51 | # "FBT", 52 | # "B", 53 | # "A", 54 | "COM", "C4", 55 | # "DTZ", 56 | # "T10", 57 | "DJ", 58 | #"EM", 59 | "EXE", 60 | #"FA", 61 | # "ISC", 62 | "ICN", 63 | # "LOG", 64 | # "G", 65 | "INP", "PIE", 66 | # "T20", 67 | "PYI", 68 | # "PT", 69 | "Q", "RSE", 70 | #"RET", 71 | #"SLF", 72 | "SLOT", 73 | # "SIM", 74 | "TID", "TCH", "INT", 75 | # "ARG", 76 | # "PTH", 77 | "TD", 78 | "FIX", 79 | # "ERA", 80 | "PD", "PGH", 81 | # "PL", 82 | # "TRY", 83 | # "FLY", 84 | "NPY", "AIR", 85 | #"PERF", 86 | # "FURB", 87 | # "RUF" 88 | ] 89 | ignore = ["E501"] 90 | 91 | # Allow fix for all enabled rules (when `--fix`) is provided. 92 | fixable = ["ALL"] 93 | unfixable = ["B"] 94 | 95 | # Allow unused variables when underscore-prefixed. 96 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 97 | # 4. Ignore `E402` (import violations) in all `__init__.py` files, and in select subdirectories. 98 | [lint.per-file-ignores] 99 | "__init__.py" = ["E402"] 100 | "**/{tests,docs,tools}/*" = ["E402"] 101 | 102 | 103 | [format] 104 | # Like Black, use double quotes for strings. 105 | quote-style = "double" 106 | 107 | # Like Black, indent with spaces, rather than tabs. 108 | indent-style = "space" 109 | 110 | # Like Black, respect magic trailing commas. 111 | skip-magic-trailing-comma = false 112 | 113 | # Like Black, automatically detect the appropriate line ending. 114 | line-ending = "auto" 115 | 116 | # Enable auto-formatting of code examples in docstrings. Markdown, 117 | # reStructuredText code/literal blocks and doctests are all supported. 118 | # 119 | # This is currently disabled by default, but it is planned for this 120 | # to be opt-out in the future. 121 | # docstring-code-format = true 122 | # docstring-code-line-length = 120 123 | 124 | # Set the line length limit used when formatting code snippets in 125 | # docstrings. 126 | # 127 | # This only has an effect when the `docstring-code-format` setting is 128 | # enabled. 129 | # docstring-code-line-length = "dynamic" -------------------------------------------------------------------------------- /generate_badges.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Generate coverage and test badges from coverage.xml and allure reports""" 3 | 4 | import json 5 | import os 6 | import shutil 7 | import sys 8 | import xml.etree.ElementTree as ET 9 | 10 | import requests 11 | 12 | 13 | def generate_badges(): 14 | """Generate SVG badges for tests and coverage""" 15 | badges_directory = "./badges" 16 | 17 | # Read test results from allure report 18 | try: 19 | with open("./allure-report/widgets/summary.json") as f: 20 | test_data = json.load(f) 21 | test_result = test_data["statistic"]["total"] == test_data["statistic"]["passed"] 22 | except FileNotFoundError: 23 | print("Warning: allure-report/widgets/summary.json not found, skipping test badge", file=sys.stderr) 24 | test_result = None 25 | 26 | # Read coverage from coverage.xml 27 | coverage_result = float(ET.parse("./coverage.xml").getroot().attrib["line-rate"]) * 100.0 28 | 29 | # Create badges directory 30 | if os.path.exists(badges_directory) and os.path.isdir(badges_directory): 31 | shutil.rmtree(badges_directory) 32 | os.mkdir(badges_directory) 33 | else: 34 | os.mkdir(badges_directory) 35 | 36 | # Generate test badge 37 | if test_result is not None: 38 | url_data = "passing&color=brightgreen" if test_result else "failing&color=critical" 39 | response = requests.get( 40 | "https://img.shields.io/static/v1?label=Tests&message=" + url_data, 41 | timeout=10, 42 | ) 43 | with open(badges_directory + "/tests.svg", "w") as f: 44 | f.write(response.text) 45 | print(f"Test badge generated: {'passing' if test_result else 'failing'}") 46 | 47 | # Generate coverage badge 48 | url_data = "brightgreen" if coverage_result == 100.0 else "critical" 49 | response = requests.get( 50 | f"https://img.shields.io/static/v1?label=Coverage&message={coverage_result:.2f}%&color={url_data}", 51 | timeout=10, 52 | ) 53 | with open(badges_directory + "/coverage.svg", "w") as f: 54 | f.write(response.text) 55 | print(f"Coverage badge generated: {coverage_result:.2f}%") 56 | 57 | # Also generate JSON badge for compatibility 58 | if coverage_result >= 95: 59 | color = "brightgreen" 60 | elif coverage_result >= 80: 61 | color = "green" 62 | elif coverage_result >= 60: 63 | color = "yellow" 64 | else: 65 | color = "red" 66 | 67 | badge_json = { 68 | "schemaVersion": 1, 69 | "label": "coverage", 70 | "message": f"{coverage_result:.2f}%", 71 | "color": color, 72 | } 73 | 74 | badge_path = "badges/coverage-badge.json" 75 | with open(badge_path, "w") as f: 76 | json.dump(badge_json, f, indent=2) 77 | 78 | print(f"Badge JSON generated: {badge_path}") 79 | 80 | # Exit with error if below threshold 81 | if coverage_result < 78: 82 | print(f"ERROR: Coverage {coverage_result:.2f}% is below 78% threshold", file=sys.stderr) 83 | sys.exit(1) 84 | 85 | sys.exit(0) 86 | 87 | 88 | if __name__ == "__main__": 89 | generate_badges() 90 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.15.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py39-plus] 7 | - repo: https://github.com/PyCQA/autoflake 8 | rev: v2.2.1 9 | hooks: 10 | - id: autoflake 11 | args: 12 | - --in-place 13 | - --remove-all-unused-imports 14 | - repo: https://github.com/psf/black 15 | rev: 23.9.1 16 | hooks: 17 | - id: black 18 | args: 19 | - --quiet 20 | files: ^((src|tests)/.+)?[^/]+\.py$ 21 | - repo: https://github.com/codespell-project/codespell 22 | rev: v2.2.6 23 | hooks: 24 | - id: codespell 25 | args: 26 | - --skip="./.*,*.csv,*.json" 27 | - --quiet-level=2 28 | exclude: ^tests/data/ 29 | - repo: https://github.com/PyCQA/flake8 30 | rev: 6.1.0 31 | hooks: 32 | - id: flake8 33 | additional_dependencies: 34 | - pycodestyle 35 | - pyflakes 36 | - flake8-docstrings 37 | - pydocstyle 38 | - flake8-comprehensions 39 | - flake8-noqa 40 | - mccabe 41 | files: ^(src|tests)/.+\.py$ 42 | - repo: https://github.com/PyCQA/bandit 43 | rev: 1.7.5 44 | hooks: 45 | - id: bandit 46 | args: 47 | - --quiet 48 | - --format=custom 49 | - --configfile=tests/bandit.yaml 50 | files: ^(src|tests)/.+\.py$ 51 | - repo: https://github.com/PyCQA/isort 52 | rev: 5.12.0 53 | hooks: 54 | - id: isort 55 | args: 56 | - --profile=black 57 | - repo: https://github.com/pre-commit/pre-commit-hooks 58 | rev: v4.5.0 59 | hooks: 60 | - id: check-executables-have-shebangs 61 | stages: [manual] 62 | - id: check-json 63 | exclude: (.vscode|.devcontainer) 64 | - id: no-commit-to-branch 65 | args: 66 | - --branch=main 67 | - repo: https://github.com/adrienverge/yamllint.git 68 | rev: v1.32.0 69 | hooks: 70 | - id: yamllint 71 | - repo: https://github.com/pre-commit/mirrors-prettier 72 | rev: v3.0.3 73 | hooks: 74 | - id: prettier 75 | # - repo: https://github.com/cdce8p/python-typing-update 76 | # rev: v0.5.0 77 | # hooks: 78 | # # Run `python-typing-update` hook manually from time to time 79 | # # to update python typing syntax. 80 | # # Will require manual work, before submitting changes! 81 | # # pre-commit run --hook-stage manual python-typing-update --all-files 82 | # - id: python-typing-update 83 | # stages: [manual] 84 | # args: 85 | # - --py39-plus 86 | # - --force 87 | # - --keep-updates 88 | # files: ^(icloudpy|tests)/.+\.py$ 89 | - repo: local 90 | hooks: 91 | # Run mypy through our wrapper script in order to get the possible 92 | # pyenv and/or virtualenv activated; it may not have been e.g. if 93 | # committing from a GUI tool that was not launched from an activated 94 | # shell. 95 | # - id: mypy 96 | # name: mypy 97 | # entry: run-in-env.sh mypy 98 | # language: script 99 | # types: [python] 100 | # require_serial: true 101 | # files: ^(icloudpy|pylint)/.+\.py$ 102 | - id: pylint 103 | name: pylint 104 | entry: run-in-env.sh pylint -j 0 105 | language: script 106 | types: [python] 107 | files: ^icloudpy/.+\.py$ 108 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utils module.""" 2 | 3 | import pytest 4 | 5 | 6 | class TestGetLocalzoneStringConversion: 7 | """Test timezone string conversion for compatibility.""" 8 | 9 | def test_str_conversion_with_zone_attribute(self): 10 | """Test that str() works with objects having .zone attribute.""" 11 | 12 | class MockTimezoneWithZone: 13 | """Mock timezone object with .zone attribute (old pytz style).""" 14 | 15 | def __init__(self, zone_name): 16 | self.zone = zone_name 17 | 18 | def __str__(self): 19 | return self.zone 20 | 21 | tz = MockTimezoneWithZone("America/New_York") 22 | assert str(tz) == "America/New_York" 23 | 24 | def test_str_conversion_with_key_attribute(self): 25 | """Test that str() works with objects having .key attribute (zoneinfo.ZoneInfo style).""" 26 | 27 | class MockTimezoneWithKey: 28 | """Mock timezone object with .key attribute (zoneinfo.ZoneInfo style).""" 29 | 30 | def __init__(self, zone_name): 31 | self.key = zone_name 32 | 33 | def __str__(self): 34 | return str(self.key) 35 | 36 | tz = MockTimezoneWithKey("Europe/London") 37 | assert str(tz) == "Europe/London" 38 | 39 | def test_str_conversion_with_no_zone_or_key(self): 40 | """Test that str() works with timezone objects without .zone or .key.""" 41 | 42 | class MockTimezoneWithStr: 43 | """Mock timezone object with only __str__ method.""" 44 | 45 | def __init__(self, zone_name): 46 | self._name = zone_name 47 | 48 | def __str__(self): 49 | return self._name 50 | 51 | tz = MockTimezoneWithStr("Asia/Tokyo") 52 | assert str(tz) == "Asia/Tokyo" 53 | 54 | def test_zoneinfo_compatibility(self): 55 | """Test compatibility with zoneinfo.ZoneInfo objects.""" 56 | try: 57 | from zoneinfo import ZoneInfo 58 | 59 | # ZoneInfo objects have .key attribute, not .zone 60 | tz = ZoneInfo("UTC") 61 | assert hasattr(tz, "key") 62 | assert not hasattr(tz, "zone") 63 | # str() should work correctly 64 | assert str(tz) == "UTC" 65 | except ImportError: 66 | # zoneinfo not available in Python < 3.9 67 | pytest.skip("zoneinfo module not available") 68 | 69 | 70 | class TestPasswordUtilities: 71 | """Test password-related utility functions.""" 72 | 73 | def test_underscore_to_camelcase_basic(self): 74 | """Test basic underscore to camelCase conversion.""" 75 | from icloudpy.utils import underscore_to_camelcase 76 | 77 | assert underscore_to_camelcase("hello_world") == "helloWorld" 78 | assert underscore_to_camelcase("foo_bar_baz") == "fooBarBaz" 79 | 80 | def test_underscore_to_camelcase_with_initial_capital(self): 81 | """Test underscore to CamelCase with initial capital.""" 82 | from icloudpy.utils import underscore_to_camelcase 83 | 84 | assert underscore_to_camelcase("hello_world", initial_capital=True) == "HelloWorld" 85 | assert underscore_to_camelcase("foo_bar_baz", initial_capital=True) == "FooBarBaz" 86 | 87 | def test_underscore_to_camelcase_single_word(self): 88 | """Test conversion of single word.""" 89 | from icloudpy.utils import underscore_to_camelcase 90 | 91 | assert underscore_to_camelcase("hello") == "hello" 92 | assert underscore_to_camelcase("hello", initial_capital=True) == "Hello" 93 | -------------------------------------------------------------------------------- /icloudpy/services/calendar.py: -------------------------------------------------------------------------------- 1 | """Calendar service.""" 2 | 3 | from calendar import monthrange 4 | from datetime import datetime 5 | 6 | from tzlocal import get_localzone 7 | 8 | 9 | class CalendarService: 10 | """ 11 | The 'Calendar' iCloud service, connects to iCloud and returns events. 12 | """ 13 | 14 | def __init__(self, service_root, session, params): 15 | self.session = session 16 | self.params = params 17 | self._service_root = service_root 18 | self._calendar_endpoint = f"{self._service_root}/ca" 19 | self._calendar_refresh_url = f"{self._calendar_endpoint}/events" 20 | self._calendar_event_detail_url = f"{self._calendar_endpoint}/eventdetail" 21 | self._calendars = f"{self._calendar_endpoint}/startup" 22 | 23 | self.response = {} 24 | 25 | def get_event_detail(self, pguid, guid): 26 | """ 27 | Fetches a single event's details by specifying a pguid 28 | (a calendar) and a guid (an event's ID). 29 | """ 30 | params = dict(self.params) 31 | params.update( 32 | { 33 | "lang": "en-us", 34 | "usertz": str(get_localzone()), 35 | "dsid": self.session.service.data["dsInfo"]["dsid"], 36 | }, 37 | ) 38 | url = f"{self._calendar_event_detail_url}/{pguid}/{guid}" 39 | req = self.session.get(url, params=params) 40 | self.response = req.json() 41 | return self.response["Event"][0] 42 | 43 | def refresh_client(self, from_dt=None, to_dt=None): 44 | """ 45 | Refreshes the CalendarService endpoint, ensuring that the 46 | event data is up-to-date. If no 'from_dt' or 'to_dt' datetimes 47 | have been given, the range becomes this month. 48 | """ 49 | today = datetime.today() 50 | _, last_day = monthrange(today.year, today.month) 51 | if not from_dt: 52 | from_dt = datetime(today.year, today.month, 1) 53 | if not to_dt: 54 | to_dt = datetime(today.year, today.month, last_day) 55 | params = dict(self.params) 56 | params.update( 57 | { 58 | "lang": "en-us", 59 | "usertz": str(get_localzone()), 60 | "startDate": from_dt.strftime("%Y-%m-%d"), 61 | "endDate": to_dt.strftime("%Y-%m-%d"), 62 | "dsid": self.session.service.data["dsInfo"]["dsid"], 63 | }, 64 | ) 65 | req = self.session.get(self._calendar_refresh_url, params=params) 66 | self.response = req.json() 67 | 68 | def events(self, from_dt=None, to_dt=None): 69 | """ 70 | Retrieves events for a given date range, by default, this month. 71 | """ 72 | self.refresh_client(from_dt, to_dt) 73 | return self.response.get("Event") 74 | 75 | def calendars(self): 76 | """ 77 | Retrieves calendars of this month. 78 | """ 79 | today = datetime.today() 80 | _, last_day = monthrange(today.year, today.month) 81 | from_dt = datetime(today.year, today.month, 1) 82 | to_dt = datetime(today.year, today.month, last_day) 83 | params = dict(self.params) 84 | params.update( 85 | { 86 | "lang": "en-us", 87 | "usertz": str(get_localzone()), 88 | "startDate": from_dt.strftime("%Y-%m-%d"), 89 | "endDate": to_dt.strftime("%Y-%m-%d"), 90 | "dsid": self.session.service.data["dsInfo"]["dsid"], 91 | }, 92 | ) 93 | req = self.session.get(self._calendars, params=params) 94 | self.response = req.json() 95 | return self.response["Collection"] 96 | -------------------------------------------------------------------------------- /tests/const_account_family.py: -------------------------------------------------------------------------------- 1 | """Account family test constants.""" 2 | 3 | # Fakers 4 | FIRST_NAME = "Quentin" 5 | LAST_NAME = "TARANTINO" 6 | FULL_NAME = FIRST_NAME + " " + LAST_NAME 7 | PERSON_ID = (FIRST_NAME + LAST_NAME).lower() 8 | PRIMARY_EMAIL = PERSON_ID + "@hotmail.fr" 9 | APPLE_ID_EMAIL = PERSON_ID + "@me.com" 10 | ICLOUD_ID_EMAIL = PERSON_ID + "@icloud.com" 11 | 12 | MEMBER_1_FIRST_NAME = "John" 13 | MEMBER_1_LAST_NAME = "TRAVOLTA" 14 | MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME 15 | MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() 16 | MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" 17 | 18 | MEMBER_2_FIRST_NAME = "Uma" 19 | MEMBER_2_LAST_NAME = "THURMAN" 20 | MEMBER_2_FULL_NAME = MEMBER_2_FIRST_NAME + " " + MEMBER_2_LAST_NAME 21 | MEMBER_2_PERSON_ID = (MEMBER_2_FIRST_NAME + MEMBER_2_LAST_NAME).lower() 22 | MEMBER_2_APPLE_ID = MEMBER_2_PERSON_ID + "@outlook.fr" 23 | 24 | FAMILY_ID = "family_" + PERSON_ID 25 | 26 | # Data 27 | ACCOUNT_FAMILY_WORKING = { 28 | "status-message": "Member of a family.", 29 | "familyInvitations": [], 30 | "outgoingTransferRequests": [], 31 | "isMemberOfFamily": True, 32 | "family": { 33 | "familyId": FAMILY_ID, 34 | "transferRequests": [], 35 | "invitations": [], 36 | "organizer": PERSON_ID, 37 | "members": [PERSON_ID, MEMBER_2_PERSON_ID, MEMBER_1_PERSON_ID], 38 | "outgoingTransferRequests": [], 39 | "etag": "12", 40 | }, 41 | "familyMembers": [ 42 | { 43 | "lastName": LAST_NAME, 44 | "dsid": PERSON_ID, 45 | "originalInvitationEmail": PRIMARY_EMAIL, 46 | "fullName": FULL_NAME, 47 | "ageClassification": "ADULT", 48 | "appleIdForPurchases": PRIMARY_EMAIL, 49 | "appleId": PRIMARY_EMAIL, 50 | "familyId": FAMILY_ID, 51 | "firstName": FIRST_NAME, 52 | "hasParentalPrivileges": True, 53 | "hasScreenTimeEnabled": False, 54 | "hasAskToBuyEnabled": False, 55 | "hasSharePurchasesEnabled": True, 56 | "shareMyLocationEnabledFamilyMembers": [], 57 | "hasShareMyLocationEnabled": True, 58 | "dsidForPurchases": PERSON_ID, 59 | }, 60 | { 61 | "lastName": MEMBER_2_LAST_NAME, 62 | "dsid": MEMBER_2_PERSON_ID, 63 | "originalInvitationEmail": MEMBER_2_APPLE_ID, 64 | "fullName": MEMBER_2_FULL_NAME, 65 | "ageClassification": "ADULT", 66 | "appleIdForPurchases": MEMBER_2_APPLE_ID, 67 | "appleId": MEMBER_2_APPLE_ID, 68 | "familyId": FAMILY_ID, 69 | "firstName": MEMBER_2_FIRST_NAME, 70 | "hasParentalPrivileges": False, 71 | "hasScreenTimeEnabled": False, 72 | "hasAskToBuyEnabled": False, 73 | "hasSharePurchasesEnabled": False, 74 | "hasShareMyLocationEnabled": False, 75 | "dsidForPurchases": MEMBER_2_PERSON_ID, 76 | }, 77 | { 78 | "lastName": MEMBER_1_LAST_NAME, 79 | "dsid": MEMBER_1_PERSON_ID, 80 | "originalInvitationEmail": MEMBER_1_APPLE_ID, 81 | "fullName": MEMBER_1_FULL_NAME, 82 | "ageClassification": "ADULT", 83 | "appleIdForPurchases": MEMBER_1_APPLE_ID, 84 | "appleId": MEMBER_1_APPLE_ID, 85 | "familyId": FAMILY_ID, 86 | "firstName": MEMBER_1_FIRST_NAME, 87 | "hasParentalPrivileges": False, 88 | "hasScreenTimeEnabled": False, 89 | "hasAskToBuyEnabled": False, 90 | "hasSharePurchasesEnabled": True, 91 | "hasShareMyLocationEnabled": True, 92 | "dsidForPurchases": MEMBER_1_PERSON_ID, 93 | }, 94 | ], 95 | "status": 0, 96 | "showAddMemberButton": True, 97 | } 98 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # iCloudPy AI Coding Agent Instructions 2 | 3 | ## Project Overview 4 | iCloudPy is a Python library wrapping iCloud web services, forked from pyiCloud. It authenticates via username/password, maintains session state locally, and exposes modular services (Drive, Photos, FindMyiPhone, Contacts, Calendar, Reminders, Account). 5 | 6 | **Key architectural pattern**: All services inherit from service classes in `icloudpy/services/` and are accessed as properties of `ICloudPyService` (e.g., `api.drive`, `api.photos`). Services use `ICloudPySession` (extends `requests.Session`) for authenticated HTTP requests. 7 | 8 | ## Critical Testing Pattern: Fixture-Based Mocking 9 | Tests use a **comprehensive mock pattern** via `tests/__init__.py`: 10 | - `ICloudPyServiceMock` replaces real API calls with fixture responses 11 | - Fixtures live in `tests/const_*.py` files (e.g., `const_drive.py`, `const_photos.py`, `const_auth.py`) 12 | - `ICloudPySessionMock.request()` intercepts URLs and returns fixture data based on URL patterns 13 | - **When adding tests**: Create or extend `const_*.py` fixtures, then update `ICloudPySessionMock.request()` to handle new URL patterns 14 | - Example: Drive folder data in `DRIVE_ROOT_WORKING` fixture, returned when URL matches `retrieveItemDetailsInFolders` 15 | 16 | ## Authentication & Session Management 17 | - Two auth flows: **2FA** (`requires_2fa`) and **2SA** (`requires_2sa`) - handle differently (see `README.md` examples) 18 | - **China region support**: Pass `home_endpoint="https://www.icloud.com.cn"` and `setup_endpoint="https://setup.icloud.com.cn/setup/ws/1"` 19 | - Session data persisted in `/icloudpy//` (cookies via `LWPCookieJar`, session tokens via JSON) 20 | - `ICloudPyPasswordFilter` automatically redacts passwords from logs 21 | 22 | ## Development Workflow 23 | **Run tests**: `pytest` (configured in `pytest.ini` with coverage, allure reports) 24 | **Lint/format**: `ruff check --fix` (must pass before tests in CI) 25 | **Full CI locally**: `./run-ci.sh` (cleans, lints, tests, generates reports, builds distribution) 26 | **Install dev deps**: `pip install -r requirements-test.txt` 27 | 28 | ## Service Structure 29 | Services initialized lazily via properties in `ICloudPyService`: 30 | ```python 31 | @property 32 | def drive(self): 33 | if not self._drive: 34 | self._drive = DriveService(service_root, document_root, self.session, self.params) 35 | return self._drive 36 | ``` 37 | 38 | Each service class receives: 39 | - `session`: Authenticated `ICloudPySession` instance 40 | - `params`: Base query params (clientId, dsid) 41 | - Service-specific roots/endpoints 42 | 43 | ## Key Files & Patterns 44 | - `icloudpy/base.py`: Core `ICloudPyService` and `ICloudPySession` classes (699 lines) 45 | - `icloudpy/services/*.py`: Individual service implementations (Drive, Photos, etc.) 46 | - `tests/const_*.py`: Mock response fixtures organized by service 47 | - `tests/__init__.py`: `ICloudPyServiceMock` and `ICloudPySessionMock` (260 lines) 48 | - `Coveragerc`: Coverage targets only `icloudpy/*` (exclude tests) 49 | 50 | ## Common Gotchas 51 | - **Don't mock `requests` directly**: Use `ICloudPyServiceMock` which intercepts at session level 52 | - **Session token validation**: `_validate_token()` checks existing session before re-auth 53 | - **Error handling**: Services raise `ICloudPyAPIResponseException`, `ICloudPyServiceNotActivatedException`, etc. - check `exceptions.py` 54 | - **Drive file downloads**: Two-step process: get token from `/download/by_id`, then fetch from `data_token.url` 55 | - **Photos**: Query-based API with recordType filters (e.g., `CPLAssetAndMasterByAddedDate` for assets) 56 | 57 | ## CI/CD Requirements 58 | - Tests run on Python 3.8+ (see `setup.py`) 59 | - Must pass `ruff check` (zero violations) 60 | - Coverage tracked with pytest-cov (HTML + XML reports) 61 | - Allure reports generated for test results 62 | 63 | ## Adding New Features 64 | 1. Add service method to appropriate `icloudpy/services/*.py` 65 | 2. Create fixture in `tests/const_.py` with expected API response 66 | 3. Update `ICloudPySessionMock.request()` to handle new URL pattern 67 | 4. Write test in `tests/test_.py` using `ICloudPyServiceMock` 68 | 5. Run `./run-ci.sh` to validate locally before pushing 69 | -------------------------------------------------------------------------------- /tests/test_findmyiphone.py: -------------------------------------------------------------------------------- 1 | """Find My iPhone service tests.""" 2 | from unittest import TestCase 3 | 4 | from . import ICloudPyServiceMock 5 | from .const import AUTHENTICATED_USER, VALID_PASSWORD 6 | 7 | 8 | class FindMyiPhoneServiceTest(TestCase): 9 | """Find My iPhone service tests.""" 10 | 11 | service = None 12 | 13 | def setUp(self): 14 | """Set up test.""" 15 | self.service = ICloudPyServiceMock(AUTHENTICATED_USER, VALID_PASSWORD) 16 | 17 | def test_devices(self): 18 | """Tests devices.""" 19 | assert len(list(self.service.devices)) == 13 20 | 21 | for device in self.service.devices: 22 | assert device["canWipeAfterLock"] is not None 23 | assert device["baUUID"] is not None 24 | assert device["wipeInProgress"] is not None 25 | assert device["lostModeEnabled"] is not None 26 | assert device["activationLocked"] is not None 27 | assert device["passcodeLength"] is not None 28 | assert device["deviceStatus"] is not None 29 | assert device["features"] is not None 30 | assert device["lowPowerMode"] is not None 31 | assert device["rawDeviceModel"] is not None 32 | assert device["id"] is not None 33 | assert device["isLocating"] is not None 34 | assert device["modelDisplayName"] is not None 35 | assert device["lostTimestamp"] is not None 36 | assert device["batteryLevel"] is not None 37 | assert device["locationEnabled"] is not None 38 | assert device["locFoundEnabled"] is not None 39 | assert device["fmlyShare"] is not None 40 | assert device["lostModeCapable"] is not None 41 | assert device["wipedTimestamp"] is None 42 | assert device["deviceDisplayName"] is not None 43 | assert device["audioChannels"] is not None 44 | assert device["locationCapable"] is not None 45 | assert device["batteryStatus"] is not None 46 | assert device["trackingInfo"] is None 47 | assert device["name"] is not None 48 | assert device["isMac"] is not None 49 | assert device["thisDevice"] is not None 50 | assert device["deviceClass"] is not None 51 | assert device["deviceModel"] is not None 52 | assert device["maxMsgChar"] is not None 53 | assert device["darkWake"] is not None 54 | assert device["remoteWipe"] is None 55 | 56 | assert device.data["canWipeAfterLock"] is not None 57 | assert device.data["baUUID"] is not None 58 | assert device.data["wipeInProgress"] is not None 59 | assert device.data["lostModeEnabled"] is not None 60 | assert device.data["activationLocked"] is not None 61 | assert device.data["passcodeLength"] is not None 62 | assert device.data["deviceStatus"] is not None 63 | assert device.data["features"] is not None 64 | assert device.data["lowPowerMode"] is not None 65 | assert device.data["rawDeviceModel"] is not None 66 | assert device.data["id"] is not None 67 | assert device.data["isLocating"] is not None 68 | assert device.data["modelDisplayName"] is not None 69 | assert device.data["lostTimestamp"] is not None 70 | assert device.data["batteryLevel"] is not None 71 | assert device.data["locationEnabled"] is not None 72 | assert device.data["locFoundEnabled"] is not None 73 | assert device.data["fmlyShare"] is not None 74 | assert device.data["lostModeCapable"] is not None 75 | assert device.data["wipedTimestamp"] is None 76 | assert device.data["deviceDisplayName"] is not None 77 | assert device.data["audioChannels"] is not None 78 | assert device.data["locationCapable"] is not None 79 | assert device.data["batteryStatus"] is not None 80 | assert device.data["trackingInfo"] is None 81 | assert device.data["name"] is not None 82 | assert device.data["isMac"] is not None 83 | assert device.data["thisDevice"] is not None 84 | assert device.data["deviceClass"] is not None 85 | assert device.data["deviceModel"] is not None 86 | assert device.data["maxMsgChar"] is not None 87 | assert device.data["darkWake"] is not None 88 | assert device.data["remoteWipe"] is None 89 | -------------------------------------------------------------------------------- /tests/test_cmdline.py: -------------------------------------------------------------------------------- 1 | """Cmdline tests.""" 2 | import os 3 | import pickle 4 | from unittest import TestCase 5 | from unittest.mock import patch # pylint: disable=no-name-in-module,import-error 6 | 7 | import pytest 8 | 9 | from icloudpy import cmdline 10 | 11 | from . import ICloudPyServiceMock 12 | from .const import AUTHENTICATED_USER, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_PASSWORD 13 | from .const_findmyiphone import FMI_FAMILY_WORKING 14 | 15 | 16 | class TestCmdline(TestCase): 17 | """Cmdline test cases.""" 18 | 19 | main = None 20 | 21 | def setUp(self): 22 | """Set up test.""" 23 | cmdline.ICloudPyService = ICloudPyServiceMock 24 | self.main = cmdline.main 25 | 26 | def test_no_arg(self): 27 | """Test no args.""" 28 | with pytest.raises(SystemExit, match="2"): 29 | self.main() 30 | 31 | with pytest.raises(SystemExit, match="2"): 32 | self.main(None) 33 | 34 | with pytest.raises(SystemExit, match="2"): 35 | self.main([]) 36 | 37 | def test_help(self): 38 | """Test the help command.""" 39 | with pytest.raises(SystemExit, match="0"): 40 | self.main(["--help"]) 41 | 42 | def test_username(self): 43 | """Test the username command.""" 44 | # No username supplied 45 | with pytest.raises(SystemExit, match="2"): 46 | self.main(["--username"]) 47 | 48 | @patch("keyring.get_password", return_value=None) 49 | @patch("getpass.getpass") 50 | def test_username_password_invalid( 51 | self, mock_getpass, mock_get_password, 52 | ): # pylint: disable=unused-argument 53 | """Test username and password commands.""" 54 | # No password supplied 55 | mock_getpass.return_value = None 56 | with pytest.raises(SystemExit, match="2"): 57 | self.main(["--username", "invalid_user"]) 58 | 59 | # Bad username or password 60 | mock_getpass.return_value = "invalid_pass" 61 | with pytest.raises( 62 | RuntimeError, match="Bad username or password for invalid_user", 63 | ): 64 | self.main(["--username", "invalid_user"]) 65 | 66 | # We should not use getpass for this one, but we reset the password at login fail 67 | with pytest.raises( 68 | RuntimeError, match="Bad username or password for invalid_user", 69 | ): 70 | self.main(["--username", "invalid_user", "--password", "invalid_pass"]) 71 | 72 | @patch("keyring.get_password", return_value=None) 73 | @patch("icloudpy.cmdline.input") 74 | def test_username_password_requires_2fa( 75 | self, mock_input, mock_get_password, 76 | ): # pylint: disable=unused-argument 77 | """Test username and password commands.""" 78 | # Valid connection for the first time 79 | mock_input.return_value = VALID_2FA_CODE 80 | with pytest.raises(SystemExit, match="0"): 81 | # fmt: off 82 | self.main([ 83 | "--username", REQUIRES_2FA_USER, 84 | "--password", VALID_PASSWORD, 85 | "--non-interactive", 86 | ]) 87 | # fmt: on 88 | 89 | @patch("keyring.get_password", return_value=None) 90 | def test_device_outputfile( 91 | self, mock_get_password, 92 | ): # pylint: disable=unused-argument 93 | """Test the outputfile command.""" 94 | with pytest.raises(SystemExit, match="0"): 95 | # fmt: off 96 | self.main([ 97 | "--username", AUTHENTICATED_USER, 98 | "--password", VALID_PASSWORD, 99 | "--non-interactive", 100 | "--outputfile", 101 | ]) 102 | # fmt: on 103 | 104 | devices = FMI_FAMILY_WORKING.get("content") 105 | for device in devices: 106 | file_name = device.get("name").strip().lower() + ".fmip_snapshot" 107 | 108 | pickle_file = open(file_name, "rb") 109 | assert pickle_file 110 | 111 | contents = [] 112 | with pickle_file as opened_file: 113 | while True: 114 | try: 115 | contents.append(pickle.load(opened_file)) 116 | except EOFError: 117 | break 118 | assert contents == [device] 119 | 120 | pickle_file.close() 121 | os.remove(file_name) 122 | -------------------------------------------------------------------------------- /icloudpy/services/reminders.py: -------------------------------------------------------------------------------- 1 | """Reminders service.""" 2 | 3 | import json 4 | import time 5 | import uuid 6 | from datetime import datetime 7 | 8 | from tzlocal import get_localzone 9 | 10 | 11 | class RemindersService: 12 | """The 'Reminders' iCloud service.""" 13 | 14 | def __init__(self, service_root, session, params): 15 | self.session = session 16 | self._params = params 17 | self._service_root = service_root 18 | 19 | self.lists = {} 20 | self.collections = {} 21 | 22 | self.refresh() 23 | 24 | def refresh(self): 25 | """Refresh data.""" 26 | params_reminders = dict(self._params) 27 | params_reminders.update( 28 | { 29 | "clientVersion": "4.0", 30 | "lang": "en-us", 31 | "usertz": str(get_localzone()), 32 | "dsid": self.session.service.data["dsInfo"]["dsid"], 33 | }, 34 | ) 35 | 36 | # Open reminders 37 | req = self.session.get( 38 | self._service_root + "/rd/startup", 39 | params=params_reminders, 40 | ) 41 | 42 | data = req.json() 43 | 44 | self.lists = {} 45 | self.collections = {} 46 | for collection in data["Collections"]: 47 | temp = [] 48 | self.collections[collection["title"]] = { 49 | "guid": collection["guid"], 50 | "ctag": collection["ctag"], 51 | } 52 | for reminder in data["Reminders"]: 53 | if reminder["pGuid"] != collection["guid"]: 54 | continue 55 | 56 | if reminder.get("dueDate"): 57 | due = datetime( 58 | reminder["dueDate"][1], 59 | reminder["dueDate"][2], 60 | reminder["dueDate"][3], 61 | reminder["dueDate"][4], 62 | reminder["dueDate"][5], 63 | ) 64 | else: 65 | due = None 66 | 67 | temp.append( 68 | { 69 | "title": reminder["title"], 70 | "desc": reminder.get("description"), 71 | "due": due, 72 | }, 73 | ) 74 | self.lists[collection["title"]] = temp 75 | 76 | def post(self, title, description="", collection=None, due_date=None): 77 | """Adds a new reminder.""" 78 | pguid = "tasks" 79 | if collection: 80 | if collection in self.collections: 81 | pguid = self.collections[collection]["guid"] 82 | 83 | params_reminders = dict(self._params) 84 | params_reminders.update( 85 | {"clientVersion": "4.0", "lang": "en-us", "usertz": str(get_localzone())}, 86 | ) 87 | 88 | due_dates = None 89 | if due_date: 90 | due_dates = [ 91 | int(str(due_date.year) + str(due_date.month) + str(due_date.day)), 92 | due_date.year, 93 | due_date.month, 94 | due_date.day, 95 | due_date.hour, 96 | due_date.minute, 97 | ] 98 | 99 | req = self.session.post( 100 | self._service_root + "/rd/reminders/tasks", 101 | data=json.dumps( 102 | { 103 | "Reminders": { 104 | "title": title, 105 | "description": description, 106 | "pGuid": pguid, 107 | "etag": None, 108 | "order": None, 109 | "priority": 0, 110 | "recurrence": None, 111 | "alarms": [], 112 | "startDate": None, 113 | "startDateTz": None, 114 | "startDateIsAllDay": False, 115 | "completedDate": None, 116 | "dueDate": due_dates, 117 | "dueDateIsAllDay": False, 118 | "lastModifiedDate": None, 119 | "createdDate": None, 120 | "isFamily": None, 121 | "createdDateExtended": int(time.time() * 1000), 122 | "guid": str(uuid.uuid4()), 123 | }, 124 | "ClientState": {"Collections": list(self.collections.values())}, 125 | }, 126 | ), 127 | params=params_reminders, 128 | ) 129 | return req.ok 130 | -------------------------------------------------------------------------------- /tests/test_account.py: -------------------------------------------------------------------------------- 1 | """Account service tests.""" 2 | from unittest import TestCase 3 | 4 | from . import ICloudPyServiceMock 5 | from .const import AUTHENTICATED_USER, VALID_PASSWORD 6 | 7 | 8 | class AccountServiceTest(TestCase): 9 | """Account service tests.""" 10 | 11 | service = None 12 | 13 | def setUp(self): 14 | """Set up test.""" 15 | self.service = ICloudPyServiceMock(AUTHENTICATED_USER, VALID_PASSWORD).account 16 | 17 | def test_repr(self): 18 | """Tests representation.""" 19 | # fmt: off 20 | assert repr(self.service) == "" 21 | # fmt: on 22 | 23 | def test_devices(self): 24 | """Tests devices.""" 25 | assert self.service.devices 26 | assert len(self.service.devices) == 2 27 | 28 | for device in self.service.devices: 29 | assert device.name 30 | assert device.model 31 | assert device.udid 32 | assert device["serialNumber"] 33 | assert device["osVersion"] 34 | assert device["modelLargePhotoURL2x"] 35 | assert device["modelLargePhotoURL1x"] 36 | assert device["paymentMethods"] 37 | assert device["name"] 38 | assert device["model"] 39 | assert device["udid"] 40 | assert device["modelSmallPhotoURL2x"] 41 | assert device["modelSmallPhotoURL1x"] 42 | assert device["modelDisplayName"] 43 | # fmt: off 44 | assert repr(device) == "" 45 | # fmt: on 46 | 47 | def test_family(self): 48 | """Tests family members.""" 49 | assert self.service.family 50 | assert len(self.service.family) == 3 51 | 52 | for member in self.service.family: 53 | assert member.last_name 54 | assert member.dsid 55 | assert member.original_invitation_email 56 | assert member.full_name 57 | assert member.age_classification 58 | assert member.apple_id_for_purchases 59 | assert member.apple_id 60 | assert member.first_name 61 | assert not member.has_screen_time_enabled 62 | assert not member.has_ask_to_buy_enabled 63 | assert not member.share_my_location_enabled_family_members 64 | assert member.dsid_for_purchases 65 | # fmt: off 66 | # pylint: disable=C0301 67 | assert repr(member) == "" # noqa: E501 68 | # fmt: on 69 | 70 | def test_storage(self): 71 | """Tests storage.""" 72 | assert self.service.storage 73 | # fmt: off 74 | # pylint: disable=C0301 75 | assert repr(self.service.storage) == "), ('backup', ), ('docs', ), ('mail', )])}>" # noqa: E501 76 | # fmt: on 77 | 78 | def test_storage_usage(self): 79 | """Tests storage usage.""" 80 | assert self.service.storage.usage 81 | usage = self.service.storage.usage 82 | assert usage.comp_storage_in_bytes or usage.comp_storage_in_bytes == 0 83 | assert usage.used_storage_in_bytes 84 | assert usage.used_storage_in_percent 85 | assert usage.available_storage_in_bytes 86 | assert usage.available_storage_in_percent 87 | assert usage.total_storage_in_bytes 88 | assert usage.commerce_storage_in_bytes or usage.commerce_storage_in_bytes == 0 89 | assert not usage.quota_over 90 | assert not usage.quota_tier_max 91 | assert not usage.quota_almost_full 92 | assert not usage.quota_paid 93 | # fmt: off 94 | # pylint: disable=C0301 95 | assert repr(usage) == "" # noqa: E501 96 | # fmt: on 97 | 98 | def test_storage_usages_by_media(self): 99 | """Tests storage usages by media.""" 100 | assert self.service.storage.usages_by_media 101 | 102 | for usage_media in self.service.storage.usages_by_media.values(): 103 | assert usage_media.key 104 | assert usage_media.label 105 | assert usage_media.color 106 | assert usage_media.usage_in_bytes or usage_media.usage_in_bytes == 0 107 | # fmt: off 108 | # pylint: disable=C0301 109 | assert repr(usage_media) == "" # noqa: E501 110 | # fmt: on 111 | -------------------------------------------------------------------------------- /tests/const_account.py: -------------------------------------------------------------------------------- 1 | """Account test constants.""" 2 | from .const_login import FIRST_NAME 3 | 4 | # Fakers 5 | PAYMENT_METHOD_ID_1 = "PAYMENT_METHOD_ID_1" 6 | PAYMENT_METHOD_ID_2 = "PAYMENT_METHOD_ID_2" 7 | PAYMENT_METHOD_ID_3 = "PAYMENT_METHOD_ID_3" 8 | PAYMENT_METHOD_ID_4 = "PAYMENT_METHOD_ID_4" 9 | 10 | # Data 11 | ACCOUNT_DEVICES_WORKING = { 12 | "devices": [ 13 | { 14 | "serialNumber": "●●●●●●●NG123", 15 | "osVersion": "OSX;10.15.3", 16 | # pylint: disable=C0301 17 | "modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox__2x.png", # noqa: E501 18 | # pylint: disable=C0301 19 | "modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox.png", # noqa: E501 20 | "paymentMethods": [PAYMENT_METHOD_ID_3], 21 | "name": "MacBook Pro de " + FIRST_NAME, 22 | "imei": "", 23 | "model": "MacBookPro15,1", 24 | "udid": "MacBookPro15,1" + FIRST_NAME, 25 | # pylint: disable=C0301 26 | "modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist__2x.png", # noqa: E501 27 | # pylint: disable=C0301 28 | "modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist.png", # noqa: E501 29 | "modelDisplayName": 'MacBook Pro 15"', 30 | }, 31 | { 32 | "serialNumber": "●●●●●●●UX123", 33 | "osVersion": "iOS;13.3", 34 | # pylint: disable=C0301 35 | "modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox__2x.png", # noqa: E501 36 | # pylint: disable=C0301 37 | "modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox.png", # noqa: E501 38 | "paymentMethods": [ 39 | PAYMENT_METHOD_ID_4, 40 | PAYMENT_METHOD_ID_2, 41 | PAYMENT_METHOD_ID_1, 42 | ], 43 | "name": "iPhone de " + FIRST_NAME, 44 | "imei": "●●●●●●●●●●12345", 45 | "model": "iPhone12,1", 46 | "udid": "iPhone12,1" + FIRST_NAME, 47 | # pylint: disable=C0301 48 | "modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist__2x.png", # noqa: E501 49 | # pylint: disable=C0301 50 | "modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist.png", # noqa: E501 51 | "modelDisplayName": "iPhone 11", 52 | }, 53 | ], 54 | "paymentMethods": [ 55 | { 56 | "lastFourDigits": "333", 57 | "balanceStatus": "NOTAPPLICABLE", 58 | "suspensionReason": "ACTIVE", 59 | "id": PAYMENT_METHOD_ID_3, 60 | "type": "Boursorama Banque", 61 | }, 62 | { 63 | "lastFourDigits": "444", 64 | "balanceStatus": "NOTAPPLICABLE", 65 | "suspensionReason": "ACTIVE", 66 | "id": PAYMENT_METHOD_ID_4, 67 | "type": "Carte Crédit Agricole", 68 | }, 69 | { 70 | "lastFourDigits": "2222", 71 | "balanceStatus": "NOTAPPLICABLE", 72 | "suspensionReason": "ACTIVE", 73 | "id": PAYMENT_METHOD_ID_2, 74 | "type": "Lydia", 75 | }, 76 | { 77 | "lastFourDigits": "111", 78 | "balanceStatus": "NOTAPPLICABLE", 79 | "suspensionReason": "ACTIVE", 80 | "id": PAYMENT_METHOD_ID_1, 81 | "type": "Boursorama Banque", 82 | }, 83 | ], 84 | } 85 | 86 | 87 | ACCOUNT_STORAGE_WORKING = { 88 | "storageUsageByMedia": [ 89 | { 90 | "mediaKey": "photos", 91 | "displayLabel": "Photos et vidéos", 92 | "displayColor": "ffcc00", 93 | "usageInBytes": 0, 94 | }, 95 | { 96 | "mediaKey": "backup", 97 | "displayLabel": "Sauvegarde", 98 | "displayColor": "5856d6", 99 | "usageInBytes": 799008186, 100 | }, 101 | { 102 | "mediaKey": "docs", 103 | "displayLabel": "Documents", 104 | "displayColor": "ff9500", 105 | "usageInBytes": 449092146, 106 | }, 107 | { 108 | "mediaKey": "mail", 109 | "displayLabel": "Mail", 110 | "displayColor": "007aff", 111 | "usageInBytes": 1101522944, 112 | }, 113 | ], 114 | "storageUsageInfo": { 115 | "compStorageInBytes": 0, 116 | "usedStorageInBytes": 2348632876, 117 | "totalStorageInBytes": 5368709120, 118 | "commerceStorageInBytes": 0, 119 | }, 120 | "quotaStatus": { 121 | "overQuota": False, 122 | "haveMaxQuotaTier": False, 123 | "almost-full": False, 124 | "paidQuota": False, 125 | }, 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/ci-main-test-coverage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: CI - Main 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | paths: 10 | - 'icloudpy/**' 11 | - 'tests/**' 12 | - 'pytest.ini' 13 | - 'requirements*.txt' 14 | - '.github/workflows/ci-main-test-coverage.yml' 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | cache-requirements-install: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout source code 25 | uses: actions/checkout@v4 26 | - name: Set up Python 3.10 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.10" 30 | - name: Cache pip dependencies 31 | uses: actions/cache@v4 32 | id: cache-dependencies 33 | with: 34 | path: ~/.cache/pip 35 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} 36 | restore-keys: | 37 | ${{ runner.os }}-pip- 38 | - name: Install dependencies 39 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 40 | run: | 41 | pip install -r requirements-test.txt 42 | test: 43 | needs: cache-requirements-install 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout source code 47 | uses: actions/checkout@v4 48 | - name: Set up Python 3.10 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: "3.10" 52 | - name: Restore pip cache dependencies 53 | uses: actions/cache@v4 54 | with: 55 | path: ~/.cache/pip 56 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} 57 | restore-keys: | 58 | ${{ runner.os }}-pip- 59 | - name: Install dependencies 60 | run: | 61 | pip install -r requirements-test.txt 62 | - name: Test with pytest 63 | run: | 64 | pytest && rm htmlcov/.gitignore 65 | - name: Upload coverage artifacts 66 | uses: actions/upload-artifact@v4 67 | if: ${{ success() }} 68 | with: 69 | name: coverage-output 70 | path: htmlcov 71 | retention-days: 1 72 | - name: Checkout gh-pages 73 | uses: actions/checkout@v4 74 | if: always() 75 | continue-on-error: true 76 | with: 77 | ref: gh-pages 78 | path: gh-pages 79 | - name: Generate Allure Report 80 | uses: simple-elf/allure-report-action@master 81 | if: always() 82 | with: 83 | allure_results: allure-results 84 | subfolder: test-results 85 | allure_report: allure-report 86 | allure_history: allure-history 87 | keep_reports: 100 88 | - name: Generate badges 89 | run: | 90 | python generate_badges.py 91 | - name: Upload tests artifacts 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: test-output 95 | path: allure-history/test-results/ 96 | retention-days: 1 97 | - name: Upload badges artifacts 98 | uses: actions/upload-artifact@v4 99 | with: 100 | name: badges-output 101 | path: badges 102 | retention-days: 1 103 | publish-test-report: 104 | needs: test 105 | runs-on: ubuntu-latest 106 | steps: 107 | - name: Download test artifacts 108 | uses: actions/download-artifact@v4 109 | with: 110 | name: test-output 111 | path: allure-history 112 | - name: Checkout gh-pages 113 | uses: actions/checkout@v4 114 | if: always() 115 | continue-on-error: true 116 | with: 117 | ref: gh-pages 118 | path: gh-pages 119 | - name: Publish test report to gh-pages 120 | if: always() 121 | uses: peaceiris/actions-gh-pages@v4 122 | with: 123 | deploy_key: ${{ secrets.DEPLOY_PRIVATE_KEY }} 124 | publish_branch: gh-pages 125 | publish_dir: allure-history 126 | destination_dir: test-results 127 | publish-coverage-report: 128 | needs: publish-test-report 129 | runs-on: ubuntu-latest 130 | steps: 131 | - name: Download coverage artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | name: coverage-output 135 | path: coverage 136 | - name: Checkout gh-pages 137 | uses: actions/checkout@v4 138 | if: always() 139 | continue-on-error: true 140 | with: 141 | ref: gh-pages 142 | path: gh-pages 143 | - name: Publish test coverage to gh-pages 144 | if: always() 145 | uses: peaceiris/actions-gh-pages@v4 146 | with: 147 | deploy_key: ${{ secrets.DEPLOY_PRIVATE_KEY }} 148 | publish_branch: gh-pages 149 | publish_dir: coverage 150 | destination_dir: test-coverage 151 | publish-badges: 152 | needs: publish-coverage-report 153 | runs-on: ubuntu-latest 154 | steps: 155 | - name: Download badges artifacts 156 | uses: actions/download-artifact@v4 157 | with: 158 | name: badges-output 159 | path: badges 160 | - name: Checkout gh-pages 161 | uses: actions/checkout@v4 162 | if: always() 163 | continue-on-error: true 164 | with: 165 | ref: gh-pages 166 | path: gh-pages 167 | - name: Publish badges to gh-pages 168 | if: always() 169 | uses: peaceiris/actions-gh-pages@v4 170 | with: 171 | deploy_key: ${{ secrets.DEPLOY_PRIVATE_KEY }} 172 | publish_branch: gh-pages 173 | publish_dir: badges 174 | destination_dir: badges -------------------------------------------------------------------------------- /icloudpy/services/findmyiphone.py: -------------------------------------------------------------------------------- 1 | """Find my iPhone service.""" 2 | 3 | import json 4 | 5 | from icloudpy.exceptions import ICloudPyNoDevicesException 6 | 7 | 8 | class FindMyiPhoneServiceManager: 9 | """The 'Find my iPhone' iCloud service 10 | 11 | This connects to iCloud and return phone data including the near-realtime 12 | latitude and longitude. 13 | """ 14 | 15 | def __init__(self, service_root, session, params, with_family=False): 16 | self.session = session 17 | self.params = params 18 | self.with_family = with_family 19 | 20 | fmip_endpoint = f"{service_root}/fmipservice/client/web" 21 | self._fmip_refresh_url = f"{fmip_endpoint}/refreshClient" 22 | self._fmip_sound_url = f"{fmip_endpoint}/playSound" 23 | self._fmip_message_url = f"{fmip_endpoint}/sendMessage" 24 | self._fmip_lost_url = f"{fmip_endpoint}/lostDevice" 25 | 26 | self._devices = {} 27 | self.refresh_client() 28 | 29 | def refresh_client(self): 30 | """Refreshes the FindMyiPhoneService endpoint, 31 | 32 | This ensures that the location data is up-to-date. 33 | 34 | """ 35 | req = self.session.post( 36 | self._fmip_refresh_url, 37 | params=self.params, 38 | data=json.dumps( 39 | { 40 | "clientContext": { 41 | "fmly": self.with_family, 42 | "shouldLocate": True, 43 | "selectedDevice": "all", 44 | "deviceListVersion": 1, 45 | }, 46 | }, 47 | ), 48 | ) 49 | self.response = req.json() 50 | 51 | for device_info in self.response["content"]: 52 | device_id = device_info["id"] 53 | if device_id not in self._devices: 54 | self._devices[device_id] = AppleDevice( 55 | device_info, 56 | self.session, 57 | self.params, 58 | manager=self, 59 | sound_url=self._fmip_sound_url, 60 | lost_url=self._fmip_lost_url, 61 | message_url=self._fmip_message_url, 62 | ) 63 | else: 64 | self._devices[device_id].update(device_info) 65 | 66 | if not self._devices: 67 | raise ICloudPyNoDevicesException 68 | 69 | def __getitem__(self, key): 70 | if isinstance(key, int): 71 | key = list(self.keys())[key] 72 | return self._devices[key] 73 | 74 | def __getattr__(self, attr): 75 | return getattr(self._devices, attr) 76 | 77 | def __unicode__(self): 78 | return str(self._devices) 79 | 80 | def __str__(self): 81 | return self.__unicode__() 82 | 83 | def __repr__(self): 84 | return str(self) 85 | 86 | 87 | class AppleDevice: 88 | """Apple device.""" 89 | 90 | def __init__( 91 | self, 92 | content, 93 | session, 94 | params, 95 | manager, 96 | sound_url=None, 97 | lost_url=None, 98 | message_url=None, 99 | ): 100 | self.content = content 101 | self.manager = manager 102 | self.session = session 103 | self.params = params 104 | 105 | self.sound_url = sound_url 106 | self.lost_url = lost_url 107 | self.message_url = message_url 108 | 109 | def update(self, data): 110 | """Updates the device data.""" 111 | self.content = data 112 | 113 | def location(self): 114 | """Updates the device location.""" 115 | self.manager.refresh_client() 116 | return self.content["location"] 117 | 118 | def status(self, additional=[]): # pylint: disable=dangerous-default-value 119 | """Returns status information for device. 120 | 121 | This returns only a subset of possible properties. 122 | """ 123 | self.manager.refresh_client() 124 | fields = ["batteryLevel", "deviceDisplayName", "deviceStatus", "name"] 125 | fields += additional 126 | properties = {} 127 | for field in fields: 128 | properties[field] = self.content.get(field) 129 | return properties 130 | 131 | def play_sound(self, subject="Find My iPhone Alert"): 132 | """Send a request to the device to play a sound. 133 | 134 | It's possible to pass a custom message by changing the `subject`. 135 | """ 136 | data = json.dumps( 137 | { 138 | "device": self.content["id"], 139 | "subject": subject, 140 | "clientContext": {"fmly": True}, 141 | }, 142 | ) 143 | self.session.post(self.sound_url, params=self.params, data=data) 144 | 145 | def display_message( 146 | self, 147 | subject="Find My iPhone Alert", 148 | message="This is a note", 149 | sounds=False, 150 | ): 151 | """Send a request to the device to play a sound. 152 | 153 | It's possible to pass a custom message by changing the `subject`. 154 | """ 155 | data = json.dumps( 156 | { 157 | "device": self.content["id"], 158 | "subject": subject, 159 | "sound": sounds, 160 | "userText": True, 161 | "text": message, 162 | }, 163 | ) 164 | self.session.post(self.message_url, params=self.params, data=data) 165 | 166 | def lost_device( 167 | self, 168 | number, 169 | text="This iPhone has been lost. Please call me.", 170 | newpasscode="", 171 | ): 172 | """Send a request to the device to trigger 'lost mode'. 173 | 174 | The device will show the message in `text`, and if a number has 175 | been passed, then the person holding the device can call 176 | the number without entering the passcode. 177 | """ 178 | data = json.dumps( 179 | { 180 | "text": text, 181 | "userText": True, 182 | "ownerNbr": number, 183 | "lostModeEnabled": True, 184 | "trackingEnabled": True, 185 | "device": self.content["id"], 186 | "passcode": newpasscode, 187 | }, 188 | ) 189 | self.session.post(self.lost_url, params=self.params, data=data) 190 | 191 | @property 192 | def data(self): 193 | """Gets the device data.""" 194 | return self.content 195 | 196 | def __getitem__(self, key): 197 | return self.content[key] 198 | 199 | def __getattr__(self, attr): 200 | return getattr(self.content, attr) 201 | 202 | def __unicode__(self): 203 | display_name = self["deviceDisplayName"] 204 | name = self["name"] 205 | return f"{display_name}: {name}" 206 | 207 | def __str__(self): 208 | return self.__unicode__() 209 | 210 | def __repr__(self): 211 | return f"" 212 | -------------------------------------------------------------------------------- /icloudpy/services/account.py: -------------------------------------------------------------------------------- 1 | """Account service.""" 2 | from collections import OrderedDict 3 | 4 | from icloudpy.utils import underscore_to_camelcase 5 | 6 | 7 | class AccountService: 8 | """The 'Account' iCloud service.""" 9 | 10 | def __init__(self, service_root, session, params): 11 | self.session = session 12 | self.params = params 13 | self._service_root = service_root 14 | 15 | self._devices = [] 16 | self._family = [] 17 | self._storage = None 18 | 19 | self._acc_endpoint = f"{self._service_root}/setup/web" 20 | self._acc_devices_url = f"{self._acc_endpoint}/device/getDevices" 21 | self._acc_family_details_url = f"{self._acc_endpoint}/family/getFamilyDetails" 22 | self._acc_family_member_photo_url = ( 23 | f"{self._acc_endpoint}/family/getMemberPhoto" 24 | ) 25 | 26 | self._acc_storage_url = "https://setup.icloud.com/setup/ws/1/storageUsageInfo" 27 | 28 | @property 29 | def devices(self): 30 | """Returns current paired devices.""" 31 | if not self._devices: 32 | req = self.session.get(self._acc_devices_url, params=self.params) 33 | response = req.json() 34 | 35 | for device_info in response["devices"]: 36 | self._devices.append(AccountDevice(device_info)) 37 | 38 | return self._devices 39 | 40 | @property 41 | def family(self): 42 | """Returns family members.""" 43 | if not self._family: 44 | req = self.session.get(self._acc_family_details_url, params=self.params) 45 | response = req.json() 46 | 47 | for member_info in response["familyMembers"]: 48 | self._family.append( 49 | FamilyMember( 50 | member_info, 51 | self.session, 52 | self.params, 53 | self._acc_family_member_photo_url, 54 | ), 55 | ) 56 | 57 | return self._family 58 | 59 | @property 60 | def storage(self): 61 | """Returns storage infos.""" 62 | if not self._storage: 63 | req = self.session.get(self._acc_storage_url, params=self.params) 64 | response = req.json() 65 | 66 | self._storage = AccountStorage(response) 67 | 68 | return self._storage 69 | 70 | def __unicode__(self): 71 | storage_available = self.storage.usage.available_storage_in_bytes 72 | return f"{{devices: {len(self.devices)}, family: {len(self.family)}, storage: {storage_available} bytes free}}" 73 | 74 | def __str__(self): 75 | return self.__unicode__() 76 | 77 | def __repr__(self): 78 | return f"<{type(self).__name__}: {str(self)}>" 79 | 80 | 81 | class AccountDevice(dict): 82 | """Account device.""" 83 | 84 | def __getattr__(self, key): 85 | return self[underscore_to_camelcase(key)] 86 | 87 | def __unicode__(self): 88 | return f"{{model: {self.model_display_name}, name: {self.name}}}" 89 | 90 | def __str__(self): 91 | return self.__unicode__() 92 | 93 | def __repr__(self): 94 | return f"<{type(self).__name__}: {str(self)}>" 95 | 96 | 97 | class FamilyMember: 98 | """A family member.""" 99 | 100 | def __init__(self, member_info, session, params, acc_family_member_photo_url): 101 | self._attrs = member_info 102 | self._session = session 103 | self._params = params 104 | self._acc_family_member_photo_url = acc_family_member_photo_url 105 | 106 | @property 107 | def last_name(self): 108 | """Gets the last name.""" 109 | return self._attrs.get("lastName") 110 | 111 | @property 112 | def dsid(self): 113 | """Gets the dsid.""" 114 | return self._attrs.get("dsid") 115 | 116 | @property 117 | def original_invitation_email(self): 118 | """Gets the original invitation.""" 119 | return self._attrs.get("originalInvitationEmail") 120 | 121 | @property 122 | def full_name(self): 123 | """Gets the full name.""" 124 | return self._attrs.get("fullName") 125 | 126 | @property 127 | def age_classification(self): 128 | """Gets the age classification.""" 129 | return self._attrs.get("ageClassification") 130 | 131 | @property 132 | def apple_id_for_purchases(self): 133 | """Gets the apple id for purchases.""" 134 | return self._attrs.get("appleIdForPurchases") 135 | 136 | @property 137 | def apple_id(self): 138 | """Gets the apple id.""" 139 | return self._attrs.get("appleId") 140 | 141 | @property 142 | def family_id(self): 143 | """Gets the family id.""" 144 | return self._attrs.get("familyId") 145 | 146 | @property 147 | def first_name(self): 148 | """Gets the first name.""" 149 | return self._attrs.get("firstName") 150 | 151 | @property 152 | def has_parental_privileges(self): 153 | """Has parental privileges.""" 154 | return self._attrs.get("hasParentalPrivileges") 155 | 156 | @property 157 | def has_screen_time_enabled(self): 158 | """Has screen time enabled.""" 159 | return self._attrs.get("hasScreenTimeEnabled") 160 | 161 | @property 162 | def has_ask_to_buy_enabled(self): 163 | """Has to ask for buying.""" 164 | return self._attrs.get("hasAskToBuyEnabled") 165 | 166 | @property 167 | def has_share_purchases_enabled(self): 168 | """Has share purshases.""" 169 | return self._attrs.get("hasSharePurchasesEnabled") 170 | 171 | @property 172 | def share_my_location_enabled_family_members(self): 173 | """Has share my location with family.""" 174 | return self._attrs.get("shareMyLocationEnabledFamilyMembers") 175 | 176 | @property 177 | def has_share_my_location_enabled(self): 178 | """Has share my location.""" 179 | return self._attrs.get("hasShareMyLocationEnabled") 180 | 181 | @property 182 | def dsid_for_purchases(self): 183 | """Gets the dsid for purchases.""" 184 | return self._attrs.get("dsidForPurchases") 185 | 186 | def get_photo(self): 187 | """Returns the photo.""" 188 | params_photo = dict(self._params) 189 | params_photo.update({"memberId": self.dsid}) 190 | return self._session.get( 191 | self._acc_family_member_photo_url, params=params_photo, stream=True, 192 | ) 193 | 194 | def __getitem__(self, key): 195 | if self._attrs.get(key): 196 | return self._attrs[key] 197 | return getattr(self, key) 198 | 199 | def __unicode__(self): 200 | return ( 201 | f"{{name: {self.full_name}, age_classification: {self.age_classification}}}" 202 | ) 203 | 204 | def __str__(self): 205 | return self.__unicode__() 206 | 207 | def __repr__(self): 208 | return f"<{type(self).__name__}: {str(self)}>" 209 | 210 | 211 | class AccountStorageUsageForMedia: 212 | """Storage used for a specific media type into the account.""" 213 | 214 | def __init__(self, usage_data): 215 | self.usage_data = usage_data 216 | 217 | @property 218 | def key(self): 219 | """Gets the key.""" 220 | return self.usage_data["mediaKey"] 221 | 222 | @property 223 | def label(self): 224 | """Gets the label.""" 225 | return self.usage_data["displayLabel"] 226 | 227 | @property 228 | def color(self): 229 | """Gets the HEX color.""" 230 | return self.usage_data["displayColor"] 231 | 232 | @property 233 | def usage_in_bytes(self): 234 | """Gets the usage in bytes.""" 235 | return self.usage_data["usageInBytes"] 236 | 237 | def __unicode__(self): 238 | return f"{{key: {self.key}, usage: {self.usage_in_bytes} bytes}}" 239 | 240 | def __str__(self): 241 | return self.__unicode__() 242 | 243 | def __repr__(self): 244 | return f"<{type(self).__name__}: {str(self)}>" 245 | 246 | 247 | class AccountStorageUsage: 248 | """Storage used for a specific media type into the account.""" 249 | 250 | def __init__(self, usage_data, quota_data): 251 | self.usage_data = usage_data 252 | self.quota_data = quota_data 253 | 254 | @property 255 | def comp_storage_in_bytes(self): 256 | """Gets the comp storage in bytes.""" 257 | return self.usage_data["compStorageInBytes"] 258 | 259 | @property 260 | def used_storage_in_bytes(self): 261 | """Gets the used storage in bytes.""" 262 | return self.usage_data["usedStorageInBytes"] 263 | 264 | @property 265 | def used_storage_in_percent(self): 266 | """Gets the used storage in percent.""" 267 | return round(self.used_storage_in_bytes * 100 / self.total_storage_in_bytes, 2) 268 | 269 | @property 270 | def available_storage_in_bytes(self): 271 | """Gets the available storage in bytes.""" 272 | return self.total_storage_in_bytes - self.used_storage_in_bytes 273 | 274 | @property 275 | def available_storage_in_percent(self): 276 | """Gets the available storage in percent.""" 277 | return round( 278 | self.available_storage_in_bytes * 100 / self.total_storage_in_bytes, 2, 279 | ) 280 | 281 | @property 282 | def total_storage_in_bytes(self): 283 | """Gets the total storage in bytes.""" 284 | return self.usage_data["totalStorageInBytes"] 285 | 286 | @property 287 | def commerce_storage_in_bytes(self): 288 | """Gets the commerce storage in bytes.""" 289 | return self.usage_data["commerceStorageInBytes"] 290 | 291 | @property 292 | def quota_over(self): 293 | """Gets the over quota.""" 294 | return self.quota_data["overQuota"] 295 | 296 | @property 297 | def quota_tier_max(self): 298 | """Gets the max tier quota.""" 299 | return self.quota_data["haveMaxQuotaTier"] 300 | 301 | @property 302 | def quota_almost_full(self): 303 | """Gets the almost full quota.""" 304 | return self.quota_data["almost-full"] 305 | 306 | @property 307 | def quota_paid(self): 308 | """Gets the paid quota.""" 309 | return self.quota_data["paidQuota"] 310 | 311 | def __unicode__(self): 312 | return f"{self.used_storage_in_percent}% used of {self.total_storage_in_bytes} bytes" 313 | 314 | def __str__(self): 315 | return self.__unicode__() 316 | 317 | def __repr__(self): 318 | return f"<{type(self).__name__}: {str(self)}>" 319 | 320 | 321 | class AccountStorage: 322 | """Storage of the account.""" 323 | 324 | def __init__(self, storage_data): 325 | self.usage = AccountStorageUsage( 326 | storage_data.get("storageUsageInfo"), storage_data.get("quotaStatus"), 327 | ) 328 | self.usages_by_media = OrderedDict() 329 | 330 | for usage_media in storage_data.get("storageUsageByMedia"): 331 | self.usages_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia( 332 | usage_media, 333 | ) 334 | 335 | def __unicode__(self): 336 | return f"{{usage: {self.usage}, usages_by_media: {self.usages_by_media}}}" 337 | 338 | def __str__(self): 339 | return self.__unicode__() 340 | 341 | def __repr__(self): 342 | return f"<{type(self).__name__}: {str(self)}>" 343 | -------------------------------------------------------------------------------- /icloudpy/cmdline.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python # noqa:EXE001 2 | """ 3 | A Command Line Wrapper to allow easy use of iCloudPy for 4 | command line scripts, and related. 5 | """ 6 | 7 | # from builtins import input 8 | import argparse 9 | import pickle 10 | import sys 11 | 12 | from click import confirm 13 | 14 | from icloudpy import ICloudPyService, utils 15 | from icloudpy.exceptions import ICloudPyFailedLoginException 16 | 17 | DEVICE_ERROR = "Please use the --device switch to indicate which device to use." 18 | 19 | 20 | def create_pickled_data(idevice, filename): 21 | """ 22 | This helper will output the idevice to a pickled file named 23 | after the passed filename. 24 | 25 | This allows the data to be used without resorting to screen / pipe 26 | scrapping. 27 | """ 28 | pickle_file = open(filename, "wb") 29 | pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) 30 | pickle_file.close() 31 | 32 | 33 | def main(args=None): 34 | """Main commandline entrypoint.""" 35 | if args is None: 36 | args = sys.argv[1:] 37 | 38 | parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool") 39 | 40 | parser.add_argument( 41 | "--username", 42 | action="store", 43 | dest="username", 44 | default="", 45 | help="Apple ID to Use", 46 | ) 47 | parser.add_argument( 48 | "--password", 49 | action="store", 50 | dest="password", 51 | default="", 52 | help=("Apple ID Password to Use; if unspecified, password will be " "fetched from the system keyring."), 53 | ) 54 | parser.add_argument( 55 | "-n", 56 | "--non-interactive", 57 | action="store_false", 58 | dest="interactive", 59 | default=True, 60 | help="Disable interactive prompts.", 61 | ) 62 | parser.add_argument( 63 | "--delete-from-keyring", 64 | action="store_true", 65 | dest="delete_from_keyring", 66 | default=False, 67 | help="Delete stored password in system keyring for this username.", 68 | ) 69 | parser.add_argument( 70 | "--list", 71 | action="store_true", 72 | dest="list", 73 | default=False, 74 | help="Short Listings for Device(s) associated with account", 75 | ) 76 | parser.add_argument( 77 | "--llist", 78 | action="store_true", 79 | dest="longlist", 80 | default=False, 81 | help="Detailed Listings for Device(s) associated with account", 82 | ) 83 | parser.add_argument( 84 | "--locate", 85 | action="store_true", 86 | dest="locate", 87 | default=False, 88 | help="Retrieve Location for the iDevice (non-exclusive).", 89 | ) 90 | 91 | # Restrict actions to a specific devices UID / DID 92 | parser.add_argument( 93 | "--device", 94 | action="store", 95 | dest="device_id", 96 | default=False, 97 | help="Only effect this device", 98 | ) 99 | 100 | # Trigger Sound Alert 101 | parser.add_argument( 102 | "--sound", 103 | action="store_true", 104 | dest="sound", 105 | default=False, 106 | help="Play a sound on the device", 107 | ) 108 | 109 | # Trigger Message w/Sound Alert 110 | parser.add_argument( 111 | "--message", 112 | action="store", 113 | dest="message", 114 | default=False, 115 | help="Optional Text Message to display with a sound", 116 | ) 117 | 118 | # Trigger Message (without Sound) Alert 119 | parser.add_argument( 120 | "--silentmessage", 121 | action="store", 122 | dest="silentmessage", 123 | default=False, 124 | help="Optional Text Message to display with no sounds", 125 | ) 126 | 127 | # Lost Mode 128 | parser.add_argument( 129 | "--lostmode", 130 | action="store_true", 131 | dest="lostmode", 132 | default=False, 133 | help="Enable Lost mode for the device", 134 | ) 135 | parser.add_argument( 136 | "--lostphone", 137 | action="store", 138 | dest="lost_phone", 139 | default=False, 140 | help="Phone Number allowed to call when lost mode is enabled", 141 | ) 142 | parser.add_argument( 143 | "--lostpassword", 144 | action="store", 145 | dest="lost_password", 146 | default=False, 147 | help="Forcibly active this passcode on the idevice", 148 | ) 149 | parser.add_argument( 150 | "--lostmessage", 151 | action="store", 152 | dest="lost_message", 153 | default="", 154 | help="Forcibly display this message when activating lost mode.", 155 | ) 156 | 157 | # Output device data to an pickle file 158 | parser.add_argument( 159 | "--outputfile", 160 | action="store_true", 161 | dest="output_to_file", 162 | default="", 163 | help="Save device data to a file in the current directory.", 164 | ) 165 | 166 | # Path to session directory 167 | parser.add_argument( 168 | "--session-directory", 169 | action="store", 170 | dest="session_directory", 171 | default=None, 172 | help="Path to save session information", 173 | ) 174 | 175 | # Server region - global or china 176 | parser.add_argument( 177 | "--region", 178 | action="store", 179 | dest="region", 180 | default="global", 181 | help="Server region - global or china", 182 | ) 183 | 184 | command_line = parser.parse_args(args) 185 | 186 | username = command_line.username 187 | password = command_line.password 188 | session_directory = command_line.session_directory 189 | server_region = command_line.region 190 | 191 | if username and command_line.delete_from_keyring: 192 | utils.delete_password_in_keyring(username) 193 | 194 | failure_count = 0 195 | while True: 196 | # Which password we use is determined by your username, so we 197 | # do need to check for this first and separately. 198 | if not username: 199 | parser.error("No username supplied") 200 | 201 | if not password: 202 | password = utils.get_password( 203 | username, 204 | interactive=command_line.interactive, 205 | ) 206 | 207 | if not password: 208 | parser.error("No password supplied") 209 | 210 | try: 211 | api = ( 212 | ICloudPyService( 213 | apple_id=username.strip(), 214 | password=password.strip(), 215 | cookie_directory=session_directory, 216 | home_endpoint="https://www.icloud.com.cn", 217 | setup_endpoint="https://setup.icloud.com.cn/setup/ws/1", 218 | ) 219 | if server_region == "china" 220 | else ICloudPyService( 221 | apple_id=username.strip(), 222 | password=password.strip(), 223 | cookie_directory=session_directory, 224 | ) 225 | ) 226 | 227 | if ( 228 | not utils.password_exists_in_keyring(username) 229 | and command_line.interactive 230 | and confirm("Save password in keyring?") 231 | ): 232 | utils.store_password_in_keyring(username, password) 233 | 234 | if api.requires_2fa: 235 | # fmt: off 236 | print( 237 | "\nTwo-step authentication required.", 238 | "\nPlease enter validation code", 239 | ) 240 | # fmt: on 241 | 242 | code = input("(string) --> ") 243 | if not api.validate_2fa_code(code): 244 | print("Failed to verify verification code") 245 | sys.exit(1) 246 | 247 | print("") 248 | 249 | elif api.requires_2sa: 250 | # fmt: off 251 | print( 252 | "\nTwo-step authentication required.", 253 | "\nYour trusted devices are:", 254 | ) 255 | # fmt: on 256 | 257 | devices = api.trusted_devices 258 | for i, device in enumerate(devices): 259 | print( 260 | f' {i}: {device.get("deviceName", "SMS to " + device.get("phoneNumber"))}', 261 | ) 262 | 263 | print("\nWhich device would you like to use?") 264 | device = int(input("(number) --> ")) 265 | device = devices[device] 266 | if not api.send_verification_code(device): 267 | print("Failed to send verification code") 268 | sys.exit(1) 269 | 270 | print("\nPlease enter validation code") 271 | code = input("(string) --> ") 272 | if not api.validate_verification_code(device, code): 273 | print("Failed to verify verification code") 274 | sys.exit(1) 275 | 276 | print("") 277 | break 278 | except ICloudPyFailedLoginException as error: 279 | # If they have a stored password; we just used it and 280 | # it did not work; let's delete it if there is one. 281 | if utils.password_exists_in_keyring(username): 282 | utils.delete_password_in_keyring(username) 283 | 284 | message = f"Bad username or password for {username}" 285 | password = None 286 | 287 | failure_count += 1 288 | if failure_count >= 3: 289 | raise RuntimeError(message) from error 290 | 291 | print(message, file=sys.stderr) 292 | 293 | for dev in api.devices: 294 | if not command_line.device_id or (command_line.device_id.strip().lower() == dev.content["id"].strip().lower()): 295 | # List device(s) 296 | if command_line.locate: 297 | dev.location() 298 | 299 | if command_line.output_to_file: 300 | create_pickled_data( 301 | dev, 302 | filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"), 303 | ) 304 | 305 | contents = dev.content 306 | if command_line.longlist: 307 | print("-" * 30) 308 | print(contents["name"]) 309 | for key in contents: 310 | print(f"{key} - {contents[key]}") 311 | elif command_line.list: 312 | print("-" * 30) 313 | print(f"Name - {contents['name']}") 314 | print(f"Display Name - {contents['deviceDisplayName']}") 315 | print(f"Location - {contents['location']}") 316 | print(f"Battery Level - {contents['batteryLevel']}") 317 | print(f"Battery Status- {contents['batteryStatus']}") 318 | print(f"Device Class - {contents['deviceClass']}") 319 | print(f"Device Model - {contents['deviceModel']}") 320 | 321 | # Play a Sound on a device 322 | if command_line.sound: 323 | if command_line.device_id: 324 | dev.play_sound() 325 | else: 326 | raise RuntimeError( 327 | f"\n\n\t\tSounds can only be played on a singular device. {DEVICE_ERROR}\n\n", 328 | ) 329 | 330 | # Display a Message on the device 331 | if command_line.message: 332 | if command_line.device_id: 333 | dev.display_message( 334 | subject="A Message", 335 | message=command_line.message, 336 | sounds=True, 337 | ) 338 | else: 339 | raise RuntimeError( 340 | f"Messages can only be played on a singular device. {DEVICE_ERROR}", 341 | ) 342 | 343 | # Display a Silent Message on the device 344 | if command_line.silentmessage: 345 | if command_line.device_id: 346 | dev.display_message( 347 | subject="A Silent Message", 348 | message=command_line.silentmessage, 349 | sounds=False, 350 | ) 351 | else: 352 | raise RuntimeError( 353 | f"Silent Messages can only be played on a singular device. {DEVICE_ERROR}", 354 | ) 355 | 356 | # Enable Lost mode 357 | if command_line.lostmode: 358 | if command_line.device_id: 359 | dev.lost_device( 360 | number=command_line.lost_phone.strip(), 361 | text=command_line.lost_message.strip(), 362 | newpasscode=command_line.lost_password.strip(), 363 | ) 364 | else: 365 | raise RuntimeError( 366 | f"Lost Mode can only be activated on a singular device. {DEVICE_ERROR}", 367 | ) 368 | sys.exit(0) 369 | 370 | 371 | if __name__ == "__main__": 372 | main() 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iCloudPy 2 | 3 | [![CI - Main](https://github.com/mandarons/icloudpy/actions/workflows/ci-main-test-coverage.yml/badge.svg)](https://github.com/mandarons/icloudpy/actions/workflows/ci-main-test-coverage.yml) 4 | [![Tests](https://mandarons.github.io/icloudpy/badges/tests.svg)](https://mandarons.github.io/icloudpy/test-results/) 5 | [![Coverage](https://mandarons.github.io/icloudpy/badges/coverage.svg)](https://mandarons.github.io/icloudpy/test-coverage/index.html) 6 | ![Python Version](https://img.shields.io/badge/python-3.10-blue) 7 | [![Discord](https://img.shields.io/discord/871555550444408883?style=for-the-badge)](https://discord.gg/BnNpJUQ2) 8 | Buy Me A Coffee 9 | 10 | :love*you_gesture: \*\*\_Please star this repository if you end up using this project. If it has improved your life in any way, consider donating for my effort using 'Buy Me a Coffee' button above. It will help me continue supporting this product.*\*\* :pray: 11 | 12 | iCloudPy is a simple iCloud webservices wrapper library written in Python. It is a major reuse of [pyiCloud](https://github.com/picklepete/pyicloud) python library. 13 | 14 | iCloudPy connects to iCloud using your `username` and `password`, stores the session locally and then performs various queries to iCloud server. 15 | 16 | ## Authentication 17 | 18 | Authentication without using a saved password is as simple as passing your username and password to the `ICloudPyService` class: 19 | 20 | ```python 21 | from icloudpy import ICloudPyService 22 | api = ICloudPyService('jappleseed@apple.com', 'password') 23 | # For China region 24 | api = ICloudPyService('jappleseed@apple.com', 'password', home_endpoint="https://www.icloud.com.cn",setup_endpoint="https://setup.icloud.com.cn/setup/ws/1",) 25 | ``` 26 | 27 | In the event that the username/password combination is invalid, a `ICloudPyFailedLoginException` exception is thrown. 28 | 29 | You can also store your password in the system keyring using the command-line tool: 30 | 31 | ```bash 32 | > icloud --username=jappleseed@apple.com 33 | ICloud Password for jappleseed@apple.com: 34 | Save password in keyring? (y/N) 35 | # For China region 36 | > icloud --username=jappleseed@apple.com --region=china 37 | ICloud Password for jappleseed@apple.com: 38 | Save password in keyring? (y/N) 39 | ``` 40 | 41 | 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 `ICloudPyService` class for the username you stored the password for. 42 | 43 | ```python 44 | api = ICloudPyService('jappleseed@apple.com') 45 | ``` 46 | 47 | 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: 48 | 49 | ```bash 50 | > icloud --username=jappleseed@apple.com --delete-from-keyring 51 | ``` 52 | 53 | **_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._** 54 | 55 | ## Two-step and two-factor authentication (2SA/2FA) 56 | 57 | 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: 58 | 59 | ```python 60 | 61 | if api.requires_2fa: 62 | print "Two-factor authentication required." 63 | code = input("Enter the code you received of one of your approved devices: ") 64 | result = api.validate_2fa_code(code) 65 | print("Code validation result: %s" % result) 66 | 67 | if not result: 68 | print("Failed to verify security code") 69 | sys.exit(1) 70 | 71 | if not api.is_trusted_session: 72 | print("Session is not trusted. Requesting trust...") 73 | result = api.trust_session() 74 | print("Session trust result %s" % result) 75 | 76 | if not result: 77 | print("Failed to request trust. You will likely be prompted for the code again in the coming weeks") 78 | elif api.requires_2sa: 79 | import click 80 | print "Two-step authentication required. Your trusted devices are:" 81 | 82 | devices = api.trusted_devices 83 | for i, device in enumerate(devices): 84 | print " %s: %s" % (i, device.get('deviceName', 85 | "SMS to %s" % device.get('phoneNumber'))) 86 | 87 | device = click.prompt('Which device would you like to use?', default=0) 88 | device = devices[device] 89 | if not api.send_verification_code(device): 90 | print "Failed to send verification code" 91 | sys.exit(1) 92 | 93 | code = click.prompt('Please enter validation code') 94 | if not api.validate_verification_code(device, code): 95 | print "Failed to verify verification code" 96 | sys.exit(1) 97 | ``` 98 | 99 | ## Devices 100 | 101 | You can list which devices associated with your account by using the `devices` property: 102 | 103 | ```bash 104 | >>> api.devices 105 | { 106 | u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': , 107 | u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': 108 | } 109 | ``` 110 | 111 | and you can access individual devices by either their index, or their ID: 112 | 113 | ```bash 114 | >>> api.devices[0] 115 | 116 | >>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] 117 | 118 | ``` 119 | 120 | 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: 121 | 122 | ```bash 123 | >>> api.iphone 124 | 125 | ``` 126 | 127 | **_Note: the first device associated with your account may not necessarily be your iPhone._** 128 | 129 | ## Find My iPhone 130 | 131 | Once you have successfully authenticated, you can start querying your data! 132 | 133 | ### Location 134 | 135 | Returns the device's last known location. The Find My iPhone app must have been installed and initialized. 136 | 137 | ```bash 138 | >>> api.iphone.location() 139 | {u'timeStamp': 1357753796553, u'locationFinished': True, u'longitude': -0.14189, u'positionType': u'GPS', u'locationType': None, u'latitude': 51.501364, u'isOld': False, u'horizontalAccuracy': 5.0} 140 | ``` 141 | 142 | ### Status 143 | 144 | The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties. 145 | 146 | ```bash 147 | >>> api.iphone.status() 148 | {'deviceDisplayName': u'iPhone 5', 'deviceStatus': u'200', 'batteryLevel': 0.6166913, 'name': u"Peter's iPhone"} 149 | ``` 150 | 151 | If you wish to request further properties, you may do so by passing in a list of property names. 152 | 153 | ### Play Sound 154 | 155 | 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. 156 | 157 | ```bash 158 | >>> api.iphone.play_sound() 159 | ``` 160 | 161 | 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. 162 | 163 | ### Lost Mode 164 | 165 | 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. 166 | 167 | ```bash 168 | >>> phone_number = '555-373-383' 169 | >>> message = 'Thief! Return my phone immediately.' 170 | >>> api.iphone.lost_device(phone_number, message) 171 | ``` 172 | 173 | ## Calendar 174 | 175 | The calendar webservice currently only supports fetching events. 176 | 177 | ### Events 178 | 179 | Returns this month's events: 180 | 181 | ```bash 182 | >>> api.calendar.events() 183 | ``` 184 | 185 | Or, between a specific date range: 186 | 187 | ```bash 188 | >>> from_dt = datetime(2012, 1, 1) 189 | >>> to_dt = datetime(2012, 1, 31) 190 | >>> api.calendar.events(from_dt, to_dt) 191 | ``` 192 | 193 | Alternatively, you may fetch a single event's details, like so: 194 | 195 | ```bash 196 | >>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID') 197 | ``` 198 | 199 | ## Contacts 200 | 201 | You can access your iCloud contacts/address book through the `contacts` property: 202 | 203 | ```bash 204 | >>> for c in api.contacts.all(): 205 | >>> print c.get('firstName'), c.get('phones') 206 | John [{u'field': u'+1 555-55-5555-5', u'label': u'MOBILE'}] 207 | ``` 208 | 209 | **_Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud._** 210 | 211 | ## File Storage (iCloud Drive) 212 | 213 | You can access your iCloud Drive through the `api.drive` property: 214 | 215 | ```bash 216 | >>> api.drive.dir() 217 | ['Holiday Photos', 'Work Files'] 218 | >>> api.drive['Holiday Photos']['2013']['Sicily'].dir() 219 | ['DSC08116.JPG', 'DSC08117.JPG'] 220 | 221 | >>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'] 222 | >>> drive_file.name 223 | u'DSC08116.JPG' 224 | >>> drive_file.date_modified 225 | datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC 226 | >>> drive_file.size 227 | 2021698 228 | >>> drive_file.type 229 | u'file' 230 | ``` 231 | 232 | The `open` method will return a response object from which you can read the file's contents: 233 | 234 | ```bash 235 | >>> from shutil import copyfileobj 236 | >>> with drive_file.open(stream=True) as response: 237 | >>> with open(drive_file.name, 'wb') as file_out: 238 | >>> copyfileobj(response.raw, file_out) 239 | ``` 240 | 241 | To interact with files and directions the `mkdir`, `rename` and `delete` functions are available 242 | for a file or folder: 243 | 244 | ```bash 245 | >>> api.drive['Holiday Photos'].mkdir('2020') 246 | >>> api.drive['Holiday Photos']['2020'].rename('2020_copy') 247 | >>> api.drive['Holiday Photos']['2020_copy'].delete() 248 | ``` 249 | 250 | The `upload` method can be used to send a file-like object to the iCloud Drive: 251 | 252 | ```bash 253 | >>> with open('Vacation.jpeg', 'rb') as file_in: 254 | >>>> api.drive['Holiday Photos'].upload(file_in) 255 | ``` 256 | 257 | It is strongly suggested to open file handles as binary rather than text to prevent decoding errors 258 | further down the line. 259 | 260 | ### Accessing App Data 261 | 262 | The `get_app_node` method can be used to retrieve a node with app data (that is not shown in `api.drive.dir()`). This is where the individual apps store related documents. 263 | 264 | ```bash 265 | >>> node = api.drive.get_app_node("XXXXXXXXXX.com.apple.iMovie") 266 | ``` 267 | 268 | Ids of individual app data can be found in `~/Library/Mobile Documents` (can only be accessed in Terminal). `~` must be replaced with `.`. 269 | 270 | Node can then be used just like any other node, supporting `mkdir`, `rename`, `delete` and so on: 271 | 272 | ``` 273 | >>> node.mkdir('2020') 274 | >>> node.rename('2020_copy') 275 | >>> node.delete() 276 | ``` 277 | 278 | ## Photo Library 279 | 280 | You can access the iCloud Photo Library through the `photos` property. 281 | 282 | ```bash 283 | >>> api.photos.all 284 | 285 | ``` 286 | 287 | Individual albums are available through the `albums` property: 288 | 289 | ```bash 290 | >>> api.photos.albums['Screenshots'] 291 | 292 | ``` 293 | 294 | 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) : 295 | 296 | ```bash 297 | >>> for photo in api.photos.albums['Screenshots']: 298 | print photo, photo.filename 299 | IMG_6045.JPG 300 | ``` 301 | 302 | To download a photo use the `download` method, which will return a [response object](http://www.python-requests.org/en/latest/api/#classes), initialized with `stream` set to `True`, so you can read from the raw response object: 303 | 304 | ```bash 305 | >>> photo = next(iter(api.photos.albums['Screenshots']), None) 306 | >>> download = photo.download() 307 | >>> with open(photo.filename, 'wb') as opened_file: 308 | opened_file.write(download.raw.read()) 309 | ``` 310 | 311 | **_Note: Consider using `shutil.copyfile` or another buffered strategy for downloading the file so that the whole file isn't read into memory before writing._** 312 | 313 | Information about each version can be accessed through the `versions` property: 314 | 315 | ```bash 316 | >>> photo.versions.keys() 317 | [u'medium', u'original', u'thumb'] 318 | ``` 319 | 320 | To download a specific version of the photo asset, pass the version to `download()`: 321 | 322 | ```bash 323 | >>> download = photo.download('thumb') 324 | >>> with open(photo.versions['thumb']['filename'], 'wb') as thumb_file: 325 | thumb_file.write(download.raw.read()) 326 | ``` 327 | -------------------------------------------------------------------------------- /icloudpy/services/drive.py: -------------------------------------------------------------------------------- 1 | """Drive service.""" 2 | 3 | import io 4 | import json 5 | import mimetypes 6 | import os 7 | import time 8 | from datetime import datetime, timedelta 9 | from re import search 10 | 11 | from requests import Response 12 | 13 | 14 | class DriveService: 15 | """The 'Drive' iCloud service.""" 16 | 17 | def __init__(self, service_root, document_root, session, params): 18 | self._service_root = service_root 19 | self._document_root = document_root 20 | self.session = session 21 | self.params = dict(params) 22 | self._root = None 23 | 24 | def _get_token_from_cookie(self): 25 | for cookie in self.session.cookies: 26 | if cookie.name == "X-APPLE-WEBAUTH-VALIDATE": 27 | match = search(r"\bt=([^:]+)", cookie.value) 28 | if match is None: 29 | raise Exception(f"Can't extract token from {cookie.value}") 30 | return {"token": match.group(1)} 31 | raise Exception("Token cookie not found") 32 | 33 | def get_node_data(self, drivewsid): 34 | """Returns the node data.""" 35 | request = self.session.post( 36 | self._service_root + "/retrieveItemDetailsInFolders", 37 | params=self.params, 38 | data=json.dumps( 39 | [ 40 | { 41 | "drivewsid": drivewsid, 42 | "partialData": False, 43 | }, 44 | ], 45 | ), 46 | ) 47 | if not request.ok: 48 | self.session.raise_error(request.status_code, request.reason) 49 | return request.json()[0] 50 | 51 | def get_file(self, file_id, zone="com.apple.CloudDocs", **kwargs): 52 | """Returns iCloud Drive file.""" 53 | file_params = dict(self.params) 54 | file_params.update({"document_id": file_id}) 55 | response = self.session.get( 56 | self._document_root + f"/ws/{zone}/download/by_id", 57 | params=file_params, 58 | ) 59 | if not response.ok: 60 | self.session.raise_error(response.status_code, response.reason) 61 | package_token = response.json().get("package_token") 62 | data_token = response.json().get("data_token") 63 | if data_token and data_token.get("url"): 64 | return self.session.get(data_token["url"], params=self.params, **kwargs) 65 | elif package_token and package_token.get("url"): 66 | return self.session.get(package_token["url"], params=self.params, **kwargs) 67 | else: 68 | raise KeyError("'data_token' nor 'package_token' found in response.") 69 | 70 | def get_app_data(self): 71 | """Returns the app library.""" 72 | request = self.session.get( 73 | self._service_root + "/retrieveAppLibraries", 74 | params=self.params, 75 | ) 76 | if not request.ok: 77 | self.session.raise_error(request.status_code, request.reason) 78 | return request.json()["items"] 79 | 80 | def get_app_node(self, app_id, folder="documents"): 81 | """Returns the node of the app.""" 82 | return DriveNode(self, self.get_node_data("FOLDER::" + app_id + "::" + folder)) 83 | 84 | def _get_upload_contentws_url(self, file_object, zone="com.apple.CloudDocs"): 85 | """Get the contentWS endpoint URL to add a new file.""" 86 | content_type = mimetypes.guess_type(file_object.name)[0] 87 | if content_type is None: 88 | content_type = "" 89 | 90 | # Get filesize from file object 91 | orig_pos = file_object.tell() 92 | file_object.seek(0, os.SEEK_END) 93 | file_size = file_object.tell() 94 | file_object.seek(orig_pos, os.SEEK_SET) 95 | 96 | file_params = self.params 97 | file_params.update(self._get_token_from_cookie()) 98 | 99 | request = self.session.post( 100 | self._document_root + f"/ws/{zone}/upload/web", 101 | params=file_params, 102 | headers={"Content-Type": "text/plain"}, 103 | data=json.dumps( 104 | { 105 | "filename": file_object.name, 106 | "type": "FILE", 107 | "content_type": content_type, 108 | "size": file_size, 109 | }, 110 | ), 111 | ) 112 | if not request.ok: 113 | self.session.raise_error(request.status_code, request.reason) 114 | return (request.json()[0]["document_id"], request.json()[0]["url"]) 115 | 116 | def _update_contentws( 117 | self, 118 | folder_id, 119 | sf_info, 120 | document_id, 121 | file_object, 122 | zone="com.apple.CloudDocs", 123 | ): 124 | data = { 125 | "data": { 126 | "signature": sf_info["fileChecksum"], 127 | "wrapping_key": sf_info["wrappingKey"], 128 | "reference_signature": sf_info["referenceChecksum"], 129 | "size": sf_info["size"], 130 | }, 131 | "command": "add_file", 132 | "create_short_guid": True, 133 | "document_id": document_id, 134 | "path": { 135 | "starting_document_id": folder_id, 136 | "path": os.path.basename(file_object.name), 137 | }, 138 | "allow_conflict": True, 139 | "file_flags": { 140 | "is_writable": True, 141 | "is_executable": False, 142 | "is_hidden": False, 143 | }, 144 | "mtime": int(time.time() * 1000), 145 | "btime": int(time.time() * 1000), 146 | } 147 | 148 | # Add the receipt if we have one. Will be absent for 0-sized files 149 | if sf_info.get("receipt"): 150 | data["data"].update({"receipt": sf_info["receipt"]}) 151 | 152 | request = self.session.post( 153 | self._document_root + f"/ws/{zone}/update/documents", 154 | params=self.params, 155 | headers={"Content-Type": "text/plain"}, 156 | data=json.dumps(data), 157 | ) 158 | if not request.ok: 159 | self.session.raise_error(request.status_code, request.reason) 160 | return request.json() 161 | 162 | def send_file(self, folder_id, file_object, zone="com.apple.CloudDocs"): 163 | """Send new file to iCloud Drive.""" 164 | document_id, content_url = self._get_upload_contentws_url(file_object, zone) 165 | 166 | request = self.session.post(content_url, files={file_object.name: file_object}) 167 | if not request.ok: 168 | self.session.raise_error(request.status_code, request.reason) 169 | content_response = request.json()["singleFile"] 170 | 171 | self._update_contentws( 172 | folder_id, 173 | content_response, 174 | document_id, 175 | file_object, 176 | zone, 177 | ) 178 | 179 | def create_folders(self, parent, name): 180 | """Creates a new iCloud Drive folder""" 181 | request = self.session.post( 182 | self._service_root + "/createFolders", 183 | params=self.params, 184 | headers={"Content-Type": "text/plain"}, 185 | data=json.dumps( 186 | { 187 | "destinationDrivewsId": parent, 188 | "folders": [ 189 | { 190 | "clientId": self.params["clientId"], 191 | "name": name, 192 | }, 193 | ], 194 | }, 195 | ), 196 | ) 197 | return request.json() 198 | 199 | def rename_items(self, node_id, etag, name): 200 | """Renames an iCloud Drive node""" 201 | request = self.session.post( 202 | self._service_root + "/renameItems", 203 | params=self.params, 204 | data=json.dumps( 205 | { 206 | "items": [ 207 | { 208 | "drivewsid": node_id, 209 | "etag": etag, 210 | "name": name, 211 | }, 212 | ], 213 | }, 214 | ), 215 | ) 216 | return request.json() 217 | 218 | def move_items_to_trash(self, node_id, etag): 219 | """Moves an iCloud Drive node to the trash bin""" 220 | request = self.session.post( 221 | self._service_root + "/moveItemsToTrash", 222 | params=self.params, 223 | data=json.dumps( 224 | { 225 | "items": [ 226 | { 227 | "drivewsid": node_id, 228 | "etag": etag, 229 | "clientId": self.params["clientId"], 230 | }, 231 | ], 232 | }, 233 | ), 234 | ) 235 | if not request.ok: 236 | self.session.raise_error(request.status_code, request.reason) 237 | return request.json() 238 | 239 | @property 240 | def root(self): 241 | """Returns the root node.""" 242 | if not self._root: 243 | self._root = DriveNode( 244 | self, 245 | self.get_node_data("FOLDER::com.apple.CloudDocs::root"), 246 | ) 247 | return self._root 248 | 249 | def __getattr__(self, attr): 250 | return getattr(self.root, attr) 251 | 252 | def __getitem__(self, key): 253 | return self.root[key] 254 | 255 | 256 | class DriveNode: 257 | """Drive node.""" 258 | 259 | def __init__(self, conn, data): 260 | self.data = data 261 | self.connection = conn 262 | self._children = None 263 | 264 | @property 265 | def name(self): 266 | """Gets the node name.""" 267 | if "extension" in self.data: 268 | return f"{self.data['name']}.{self.data['extension']}" 269 | return self.data["name"] 270 | 271 | @property 272 | def type(self): 273 | """Gets the node type.""" 274 | node_type = self.data.get("type") 275 | return node_type and node_type.lower() 276 | 277 | def get_children(self): 278 | """Gets the node children.""" 279 | if not self._children: 280 | if "items" not in self.data: 281 | self.data.update(self.connection.get_node_data(self.data["drivewsid"])) 282 | if "items" not in self.data: 283 | raise KeyError(f"No items in folder, status: {self.data['status']}") 284 | self._children = [DriveNode(self.connection, item_data) for item_data in self.data["items"]] 285 | return self._children 286 | 287 | @property 288 | def size(self): 289 | """Gets the node size.""" 290 | size = self.data.get("size") # Folder does not have size 291 | if not size: 292 | return None 293 | return int(size) 294 | 295 | @property 296 | def date_created(self): 297 | """Gets the node created date (in UTC).""" 298 | return _date_to_utc(self.data.get("dateCreated")) 299 | 300 | @property 301 | def date_changed(self): 302 | """Gets the node changed date (in UTC).""" 303 | return _date_to_utc(self.data.get("dateChanged")) # Folder does not have date 304 | 305 | @property 306 | def date_modified(self): 307 | """Gets the node modified date (in UTC).""" 308 | return _date_to_utc(self.data.get("dateModified")) # Folder does not have date 309 | 310 | @property 311 | def date_last_open(self): 312 | """Gets the node last open date (in UTC).""" 313 | return _date_to_utc(self.data.get("lastOpenTime")) # Folder does not have date 314 | 315 | def open(self, **kwargs): 316 | """Gets the node file.""" 317 | # iCloud returns 400 Bad Request for 0-byte files 318 | if self.data["size"] == 0: 319 | response = Response() 320 | response.raw = io.BytesIO() 321 | return response 322 | return self.connection.get_file( 323 | self.data["docwsid"], 324 | zone=self.data["zone"], 325 | **kwargs, 326 | ) 327 | 328 | def upload(self, file_object, **kwargs): 329 | """ "Upload a new file.""" 330 | return self.connection.send_file( 331 | self.data["docwsid"], 332 | file_object, 333 | zone=self.data["zone"], 334 | **kwargs, 335 | ) 336 | 337 | def dir(self): 338 | """Gets the node list of directories.""" 339 | if self.type == "file": 340 | return None 341 | return [child.name for child in self.get_children()] 342 | 343 | def mkdir(self, folder): 344 | """Create a new directory directory.""" 345 | # remove cached entries information first so that it will be re-read on next get_children() 346 | self._children = None 347 | if "items" in self.data: 348 | self.data.pop("items") 349 | return self.connection.create_folders(self.data["drivewsid"], folder) 350 | 351 | def rename(self, name): 352 | """Rename an iCloud Drive item.""" 353 | return self.connection.rename_items( 354 | self.data["drivewsid"], 355 | self.data["etag"], 356 | name, 357 | ) 358 | 359 | def delete(self): 360 | """Delete an iCloud Drive item.""" 361 | return self.connection.move_items_to_trash( 362 | self.data["drivewsid"], 363 | self.data["etag"], 364 | ) 365 | 366 | def get(self, name): 367 | """Gets the node child.""" 368 | if self.type == "file": 369 | return None 370 | return [child for child in self.get_children() if child.name == name][0] 371 | 372 | def __getitem__(self, key): 373 | try: 374 | return self.get(key) 375 | except IndexError as error: 376 | raise KeyError(f"No child named '{key}' exists") from error 377 | 378 | def __unicode__(self): 379 | return f"{{type: {self.type}, name: {self.name}}}" 380 | 381 | def __str__(self): 382 | return self.__unicode__() 383 | 384 | def __repr__(self): 385 | return f"<{type(self).__name__}: {str(self)}>" 386 | 387 | 388 | def _date_to_utc(date): 389 | if not date: 390 | return None 391 | # jump through hoops to return time in UTC rather than California time 392 | match = search(r"^(.+?)([\+\-]\d+):(\d\d)$", date) 393 | if not match: 394 | # Already in UTC 395 | return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") 396 | base = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S") 397 | diff = timedelta(hours=int(match.group(2)), minutes=int(match.group(3))) 398 | return base - diff 399 | -------------------------------------------------------------------------------- /tests/const_login.py: -------------------------------------------------------------------------------- 1 | """Login test constants.""" 2 | 3 | from .const_account_family import ( 4 | APPLE_ID_EMAIL, 5 | FIRST_NAME, 6 | FULL_NAME, 7 | ICLOUD_ID_EMAIL, 8 | LAST_NAME, 9 | PRIMARY_EMAIL, 10 | ) 11 | 12 | # Re-export auth constants for backwards compatibility 13 | from .const_auth import ( # noqa: F401 14 | AUTH_OK, 15 | SESSION_VALID, 16 | SRP_INIT_OK, 17 | TRUST_TOKEN_OK, 18 | TRUSTED_DEVICE_1, 19 | TRUSTED_DEVICES, 20 | VERIFICATION_CODE_KO, 21 | VERIFICATION_CODE_OK, 22 | ) 23 | 24 | PERSON_ID = (FIRST_NAME + LAST_NAME).lower() 25 | NOTIFICATION_ID = "12345678-1234-1234-1234-123456789012" + PERSON_ID 26 | A_DS_ID = "123456-12-12345678-1234-1234-1234-123456789012" + PERSON_ID 27 | WIDGET_KEY = "widget_key" + PERSON_ID 28 | 29 | LOGIN_WORKING = { 30 | "dsInfo": { 31 | "lastName": LAST_NAME, 32 | "iCDPEnabled": False, 33 | "tantorMigrated": True, 34 | "dsid": PERSON_ID, 35 | "hsaEnabled": True, 36 | "ironcadeMigrated": True, 37 | "locale": "fr-fr_FR", 38 | "brZoneConsolidated": False, 39 | "isManagedAppleID": False, 40 | "gilligan-invited": "true", 41 | "appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL], 42 | "hsaVersion": 2, 43 | "isPaidDeveloper": False, 44 | "countryCode": "FRA", 45 | "notificationId": NOTIFICATION_ID, 46 | "primaryEmailVerified": True, 47 | "aDsID": A_DS_ID, 48 | "locked": False, 49 | "hasICloudQualifyingDevice": True, 50 | "primaryEmail": PRIMARY_EMAIL, 51 | "appleIdEntries": [ 52 | {"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL}, 53 | {"type": "EMAIL", "value": APPLE_ID_EMAIL}, 54 | {"type": "EMAIL", "value": ICLOUD_ID_EMAIL}, 55 | ], 56 | "gilligan-enabled": "true", 57 | "fullName": FULL_NAME, 58 | "languageCode": "fr-fr", 59 | "appleId": PRIMARY_EMAIL, 60 | "firstName": FIRST_NAME, 61 | "iCloudAppleIdAlias": ICLOUD_ID_EMAIL, 62 | "notesMigrated": True, 63 | "hasPaymentInfo": False, 64 | "pcsDeleted": False, 65 | "appleIdAlias": APPLE_ID_EMAIL, 66 | "brMigrated": True, 67 | "statusCode": 2, 68 | "familyEligible": True, 69 | }, 70 | "hasMinimumDeviceForPhotosWeb": True, 71 | "iCDPEnabled": False, 72 | "webservices": { 73 | "reminders": { 74 | "url": "https://p31-remindersws.icloud.com:443", 75 | "status": "active", 76 | }, 77 | "notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"}, 78 | "mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"}, 79 | "ckdatabasews": { 80 | "pcsRequired": True, 81 | "url": "https://p31-ckdatabasews.icloud.com:443", 82 | "status": "active", 83 | }, 84 | "photosupload": { 85 | "pcsRequired": True, 86 | "url": "https://p31-uploadphotosws.icloud.com:443", 87 | "status": "active", 88 | }, 89 | "photos": { 90 | "pcsRequired": True, 91 | "uploadUrl": "https://p31-uploadphotosws.icloud.com:443", 92 | "url": "https://p31-photosws.icloud.com:443", 93 | "status": "active", 94 | }, 95 | "drivews": { 96 | "pcsRequired": True, 97 | "url": "https://p31-drivews.icloud.com:443", 98 | "status": "active", 99 | }, 100 | "uploadimagews": { 101 | "url": "https://p31-uploadimagews.icloud.com:443", 102 | "status": "active", 103 | }, 104 | "schoolwork": {}, 105 | "cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"}, 106 | "findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"}, 107 | "ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"}, 108 | "iworkthumbnailws": { 109 | "url": "https://p31-iworkthumbnailws.icloud.com:443", 110 | "status": "active", 111 | }, 112 | "calendar": { 113 | "url": "https://p31-calendarws.icloud.com:443", 114 | "status": "active", 115 | }, 116 | "docws": { 117 | "pcsRequired": True, 118 | "url": "https://p31-docws.icloud.com:443", 119 | "status": "active", 120 | }, 121 | "settings": { 122 | "url": "https://p31-settingsws.icloud.com:443", 123 | "status": "active", 124 | }, 125 | "streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"}, 126 | "keyvalue": { 127 | "url": "https://p31-keyvalueservice.icloud.com:443", 128 | "status": "active", 129 | }, 130 | "archivews": { 131 | "url": "https://p31-archivews.icloud.com:443", 132 | "status": "active", 133 | }, 134 | "push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"}, 135 | "iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"}, 136 | "iworkexportws": { 137 | "url": "https://p31-iworkexportws.icloud.com:443", 138 | "status": "active", 139 | }, 140 | "geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"}, 141 | "account": { 142 | "iCloudEnv": {"shortId": "p", "vipSuffix": "prod"}, 143 | "url": "https://p31-setup.icloud.com:443", 144 | "status": "active", 145 | }, 146 | "fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"}, 147 | "contacts": { 148 | "url": "https://p31-contactsws.icloud.com:443", 149 | "status": "active", 150 | }, 151 | }, 152 | "pcsEnabled": True, 153 | "configBag": { 154 | "urls": { 155 | "accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey=" 156 | + WIDGET_KEY 157 | + "#!create", 158 | "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=" 159 | + WIDGET_KEY, 160 | "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin", 161 | "accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey=" 162 | + WIDGET_KEY 163 | + "#!repair", 164 | "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms", 165 | "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone", 166 | "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=" 167 | + WIDGET_KEY, 168 | "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail", 169 | "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount", 170 | "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms", 171 | "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone", 172 | }, 173 | "accountCreateEnabled": "true", 174 | }, 175 | "hsaTrustedBrowser": True, 176 | "appsOrder": [ 177 | "mail", 178 | "contacts", 179 | "calendar", 180 | "photos", 181 | "iclouddrive", 182 | "notes3", 183 | "reminders", 184 | "pages", 185 | "numbers", 186 | "keynote", 187 | "newspublisher", 188 | "fmf", 189 | "find", 190 | "settings", 191 | ], 192 | "version": 2, 193 | "isExtendedLogin": True, 194 | "pcsServiceIdentitiesIncluded": True, 195 | "hsaChallengeRequired": False, 196 | "requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"}, 197 | "pcsDeleted": False, 198 | "iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True}, 199 | "apps": { 200 | "calendar": {}, 201 | "reminders": {}, 202 | "keynote": {"isQualifiedForBeta": True}, 203 | "settings": {"canLaunchWithOneFactor": True}, 204 | "mail": {}, 205 | "numbers": {"isQualifiedForBeta": True}, 206 | "photos": {}, 207 | "pages": {"isQualifiedForBeta": True}, 208 | "notes3": {}, 209 | "find": {"canLaunchWithOneFactor": True}, 210 | "iclouddrive": {}, 211 | "newspublisher": {"isHidden": True}, 212 | "fmf": {}, 213 | "contacts": {}, 214 | }, 215 | } 216 | 217 | # Setup data 218 | LOGIN_2FA = { 219 | "dsInfo": { 220 | "lastName": LAST_NAME, 221 | "iCDPEnabled": False, 222 | "tantorMigrated": True, 223 | "dsid": PERSON_ID, 224 | "hsaEnabled": True, 225 | "ironcadeMigrated": True, 226 | "locale": "fr-fr_FR", 227 | "brZoneConsolidated": False, 228 | "isManagedAppleID": False, 229 | "gilligan-invited": "true", 230 | "appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL], 231 | "hsaVersion": 2, 232 | "isPaidDeveloper": False, 233 | "countryCode": "FRA", 234 | "notificationId": NOTIFICATION_ID, 235 | "primaryEmailVerified": True, 236 | "aDsID": A_DS_ID, 237 | "locked": False, 238 | "hasICloudQualifyingDevice": True, 239 | "primaryEmail": PRIMARY_EMAIL, 240 | "appleIdEntries": [ 241 | {"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL}, 242 | {"type": "EMAIL", "value": APPLE_ID_EMAIL}, 243 | {"type": "EMAIL", "value": ICLOUD_ID_EMAIL}, 244 | ], 245 | "gilligan-enabled": "true", 246 | "fullName": FULL_NAME, 247 | "languageCode": "fr-fr", 248 | "appleId": PRIMARY_EMAIL, 249 | "firstName": FIRST_NAME, 250 | "iCloudAppleIdAlias": ICLOUD_ID_EMAIL, 251 | "notesMigrated": True, 252 | "hasPaymentInfo": True, 253 | "pcsDeleted": False, 254 | "appleIdAlias": APPLE_ID_EMAIL, 255 | "brMigrated": True, 256 | "statusCode": 2, 257 | "familyEligible": True, 258 | }, 259 | "hasMinimumDeviceForPhotosWeb": True, 260 | "iCDPEnabled": False, 261 | "webservices": { 262 | "reminders": { 263 | "url": "https://p31-remindersws.icloud.com:443", 264 | "status": "active", 265 | }, 266 | "notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"}, 267 | "mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"}, 268 | "ckdatabasews": { 269 | "pcsRequired": True, 270 | "url": "https://p31-ckdatabasews.icloud.com:443", 271 | "status": "active", 272 | }, 273 | "photosupload": { 274 | "pcsRequired": True, 275 | "url": "https://p31-uploadphotosws.icloud.com:443", 276 | "status": "active", 277 | }, 278 | "photos": { 279 | "pcsRequired": True, 280 | "uploadUrl": "https://p31-uploadphotosws.icloud.com:443", 281 | "url": "https://p31-photosws.icloud.com:443", 282 | "status": "active", 283 | }, 284 | "drivews": { 285 | "pcsRequired": True, 286 | "url": "https://p31-drivews.icloud.com:443", 287 | "status": "active", 288 | }, 289 | "uploadimagews": { 290 | "url": "https://p31-uploadimagews.icloud.com:443", 291 | "status": "active", 292 | }, 293 | "schoolwork": {}, 294 | "cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"}, 295 | "findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"}, 296 | "ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"}, 297 | "iworkthumbnailws": { 298 | "url": "https://p31-iworkthumbnailws.icloud.com:443", 299 | "status": "active", 300 | }, 301 | "calendar": { 302 | "url": "https://p31-calendarws.icloud.com:443", 303 | "status": "active", 304 | }, 305 | "docws": { 306 | "pcsRequired": True, 307 | "url": "https://p31-docws.icloud.com:443", 308 | "status": "active", 309 | }, 310 | "settings": { 311 | "url": "https://p31-settingsws.icloud.com:443", 312 | "status": "active", 313 | }, 314 | "streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"}, 315 | "keyvalue": { 316 | "url": "https://p31-keyvalueservice.icloud.com:443", 317 | "status": "active", 318 | }, 319 | "archivews": { 320 | "url": "https://p31-archivews.icloud.com:443", 321 | "status": "active", 322 | }, 323 | "push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"}, 324 | "iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"}, 325 | "iworkexportws": { 326 | "url": "https://p31-iworkexportws.icloud.com:443", 327 | "status": "active", 328 | }, 329 | "geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"}, 330 | "account": { 331 | "iCloudEnv": {"shortId": "p", "vipSuffix": "prod"}, 332 | "url": "https://p31-setup.icloud.com:443", 333 | "status": "active", 334 | }, 335 | "fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"}, 336 | "contacts": { 337 | "url": "https://p31-contactsws.icloud.com:443", 338 | "status": "active", 339 | }, 340 | }, 341 | "pcsEnabled": True, 342 | "configBag": { 343 | "urls": { 344 | "accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey=" 345 | + WIDGET_KEY 346 | + "#!create", 347 | "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=" 348 | + WIDGET_KEY, 349 | "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin", 350 | "accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey=" 351 | + WIDGET_KEY 352 | + "#!repair", 353 | "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms", 354 | "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone", 355 | "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=" 356 | + WIDGET_KEY, 357 | "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail", 358 | "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount", 359 | "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms", 360 | "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone", 361 | }, 362 | "accountCreateEnabled": "true", 363 | }, 364 | "hsaTrustedBrowser": False, 365 | "appsOrder": [ 366 | "mail", 367 | "contacts", 368 | "calendar", 369 | "photos", 370 | "iclouddrive", 371 | "notes3", 372 | "reminders", 373 | "pages", 374 | "numbers", 375 | "keynote", 376 | "newspublisher", 377 | "fmf", 378 | "find", 379 | "settings", 380 | ], 381 | "version": 2, 382 | "isExtendedLogin": True, 383 | "pcsServiceIdentitiesIncluded": False, 384 | "hsaChallengeRequired": True, 385 | "requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"}, 386 | "pcsDeleted": False, 387 | "iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True}, 388 | "apps": { 389 | "calendar": {}, 390 | "reminders": {}, 391 | "keynote": {"isQualifiedForBeta": True}, 392 | "settings": {"canLaunchWithOneFactor": True}, 393 | "mail": {}, 394 | "numbers": {"isQualifiedForBeta": True}, 395 | "photos": {}, 396 | "pages": {"isQualifiedForBeta": True}, 397 | "notes3": {}, 398 | "find": {"canLaunchWithOneFactor": True}, 399 | "iclouddrive": {}, 400 | "newspublisher": {"isHidden": True}, 401 | "fmf": {}, 402 | "contacts": {}, 403 | }, 404 | } 405 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # This Pylint rcfile contains a best-effort configuration to uphold the 2 | # best-practices and style described in the Google Python style guide: 3 | # https://google.github.io/styleguide/pyguide.html 4 | # 5 | # Its canonical open-source location is: 6 | # https://google.github.io/styleguide/pylintrc 7 | 8 | [MASTER] 9 | 10 | # Files or directories to be skipped. They should be base names, not paths. 11 | ignore=third_party 12 | 13 | # Files or directories matching the regex patterns are skipped. The regex 14 | # matches against base names, not paths. 15 | ignore-patterns=photos_data.py 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=no 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | # Use multiple processes to speed up Pylint. 25 | jobs=4 26 | 27 | # Allow loading of arbitrary C extensions. Extensions are imported into the 28 | # active Python interpreter and may run arbitrary code. 29 | unsafe-load-any-extension=no 30 | 31 | 32 | [MESSAGES CONTROL] 33 | 34 | # Only show warnings with the listed confidence levels. Leave empty to show 35 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 36 | confidence= 37 | 38 | # Enable the message, report, category or checker with the given id(s). You can 39 | # either give multiple identifier separated by comma (,) or put this option 40 | # multiple time (only on the command line, not in the configuration file where 41 | # it should appear only once). See also the "--disable" option for examples. 42 | #enable= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then re-enable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable=abstract-method, 54 | apply-builtin, 55 | arguments-differ, 56 | attribute-defined-outside-init, 57 | backtick, 58 | bad-option-value, 59 | basestring-builtin, 60 | buffer-builtin, 61 | c-extension-no-member, 62 | consider-using-enumerate, 63 | consider-using-with, 64 | cmp-builtin, 65 | cmp-method, 66 | coerce-builtin, 67 | coerce-method, 68 | delslice-method, 69 | div-method, 70 | duplicate-code, 71 | eq-without-hash, 72 | execfile-builtin, 73 | file-builtin, 74 | filter-builtin-not-iterating, 75 | fixme, 76 | getslice-method, 77 | global-statement, 78 | hex-method, 79 | idiv-method, 80 | implicit-str-concat-in-sequence, 81 | import-error, 82 | import-self, 83 | import-star-module-level, 84 | inconsistent-return-statements, 85 | input-builtin, 86 | intern-builtin, 87 | invalid-str-codec, 88 | locally-disabled, 89 | long-builtin, 90 | long-suffix, 91 | map-builtin-not-iterating, 92 | misplaced-comparison-constant, 93 | missing-function-docstring, 94 | missing-module-docstring, 95 | missing-class-docstring, 96 | metaclass-assignment, 97 | next-method-called, 98 | next-method-defined, 99 | no-absolute-import, 100 | no-else-break, 101 | no-else-continue, 102 | no-else-raise, 103 | no-else-return, 104 | no-init, # added 105 | no-member, 106 | no-name-in-module, 107 | no-self-use, 108 | nonzero-method, 109 | oct-method, 110 | old-division, 111 | old-ne-operator, 112 | old-octal-literal, 113 | old-raise-syntax, 114 | parameter-unpacking, 115 | print-statement, 116 | raising-string, 117 | range-builtin-not-iterating, 118 | raw_input-builtin, 119 | rdiv-method, 120 | reduce-builtin, 121 | relative-import, 122 | reload-builtin, 123 | round-builtin, 124 | setslice-method, 125 | signature-differs, 126 | standarderror-builtin, 127 | suppressed-message, 128 | sys-max-int, 129 | too-few-public-methods, 130 | too-many-ancestors, 131 | too-many-arguments, 132 | too-many-boolean-expressions, 133 | too-many-branches, 134 | too-many-instance-attributes, 135 | too-many-locals, 136 | too-many-nested-blocks, 137 | too-many-public-methods, 138 | too-many-return-statements, 139 | too-many-statements, 140 | trailing-newlines, 141 | unichr-builtin, 142 | unicode-builtin, 143 | unnecessary-pass, 144 | unpacking-in-except, 145 | unused-argument, 146 | useless-else-on-loop, 147 | useless-object-inheritance, 148 | useless-suppression, 149 | using-cmp-argument, 150 | wrong-import-order, 151 | xrange-builtin, 152 | zip-builtin-not-iterating, 153 | protected-access, 154 | logging-not-lazy, 155 | logging-fstring-interpolation 156 | 157 | 158 | [REPORTS] 159 | 160 | # Set the output format. Available formats are text, parseable, colorized, msvs 161 | # (visual studio) and html. You can also give a reporter class, eg 162 | # mypackage.mymodule.MyReporterClass. 163 | output-format=text 164 | 165 | # Put messages in a separate file for each module / package specified on the 166 | # command line instead of printing them on stdout. Reports (if any) will be 167 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 168 | # and it will be removed in Pylint 2.0. 169 | # files-output=no 170 | 171 | # Tells whether to display a full report or only the messages 172 | reports=no 173 | 174 | # Python expression which should return a note less than 10 (10 is the highest 175 | # note). You have access to the variables errors warning, statement which 176 | # respectively contain the number of errors / warnings messages and the total 177 | # number of statements analyzed. This is used by the global evaluation report 178 | # (RP0004). 179 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 180 | 181 | # Template used to display messages. This is a python new-style format string 182 | # used to format the message information. See doc for all details 183 | #msg-template= 184 | 185 | 186 | [BASIC] 187 | 188 | # Good variable names which should always be accepted, separated by a comma 189 | good-names=main,_ 190 | 191 | # Bad variable names which should always be refused, separated by a comma 192 | bad-names= 193 | 194 | # Colon-delimited sets of names that determine each other's naming style when 195 | # the name regexes allow several styles. 196 | name-group= 197 | 198 | # Include a hint for the correct naming format with invalid-name 199 | include-naming-hint=no 200 | 201 | # List of decorators that produce properties, such as abc.abstractproperty. Add 202 | # to this list to register other decorators that produce valid properties. 203 | property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl 204 | 205 | # Regular expression matching correct function names 206 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 207 | 208 | # Regular expression matching correct variable names 209 | variable-rgx=^[a-z][a-z0-9_]*$ 210 | 211 | # Regular expression matching correct constant names 212 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 213 | 214 | # Regular expression matching correct attribute names 215 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 216 | 217 | # Regular expression matching correct argument names 218 | argument-rgx=^[a-z][a-z0-9_]*$ 219 | 220 | # Regular expression matching correct class attribute names 221 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 222 | 223 | # Regular expression matching correct inline iteration names 224 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 225 | 226 | # Regular expression matching correct class names 227 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 228 | 229 | # Regular expression matching correct module names 230 | module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ 231 | 232 | # Regular expression matching correct method names 233 | method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ 234 | 235 | # Regular expression which should only match function or class names that do 236 | # not require a docstring. 237 | no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ 238 | 239 | # Minimum line length for functions/classes that require docstrings, shorter 240 | # ones are exempt. 241 | docstring-min-length=10 242 | 243 | 244 | [TYPECHECK] 245 | 246 | # List of decorators that produce context managers, such as 247 | # contextlib.contextmanager. Add to this list to register other decorators that 248 | # produce valid context managers. 249 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 250 | 251 | # Tells whether missing members accessed in mixin class should be ignored. A 252 | # mixin class is detected if its name ends with "mixin" (case insensitive). 253 | ignore-mixin-members=yes 254 | 255 | # List of module names for which member attributes should not be checked 256 | # (useful for modules/projects where namespaces are manipulated during runtime 257 | # and thus existing member attributes cannot be deduced by static analysis. It 258 | # supports qualified module names, as well as Unix pattern matching. 259 | ignored-modules= 260 | 261 | # List of class names for which member attributes should not be checked (useful 262 | # for classes with dynamically set attributes). This supports the use of 263 | # qualified names. 264 | ignored-classes=optparse.Values,thread._local,_thread._local 265 | 266 | # List of members which are set dynamically and missed by pylint inference 267 | # system, and so shouldn't trigger E1101 when accessed. Python regular 268 | # expressions are accepted. 269 | generated-members= 270 | 271 | 272 | [FORMAT] 273 | 274 | # Maximum number of characters on a single line. 275 | max-line-length=120 276 | 277 | # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt 278 | # lines made too long by directives to pytype. 279 | 280 | # Regexp for a line that is allowed to be longer than the limit. 281 | ignore-long-lines=(?x)( 282 | ^\s*(\#\ )??$| 283 | ^\s*(from\s+\S+\s+)?import\s+.+$) 284 | 285 | # Allow the body of an if to be on the same line as the test if there is no 286 | # else. 287 | single-line-if-stmt=yes 288 | 289 | # List of optional constructs for which whitespace checking is disabled. `dict- 290 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 291 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 292 | # `empty-line` allows space-only lines. 293 | # no-space-check= 294 | 295 | # Maximum number of lines in a module 296 | max-module-lines=99999 297 | 298 | # String used as indentation unit. The internal Google style guide mandates 2 299 | # spaces. Google's externaly-published style guide says 4, consistent with 300 | # PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google 301 | # projects (like TensorFlow). 302 | indent-string=' ' 303 | 304 | # Number of spaces of indent required inside a hanging or continued line. 305 | indent-after-paren=4 306 | 307 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 308 | expected-line-ending-format= 309 | 310 | 311 | [MISCELLANEOUS] 312 | 313 | # List of note tags to take in consideration, separated by a comma. 314 | notes=TODO 315 | 316 | 317 | [STRING] 318 | 319 | # This flag controls whether inconsistent-quotes generates a warning when the 320 | # character used as a quote delimiter is used inconsistently within a module. 321 | check-quote-consistency=yes 322 | 323 | 324 | [VARIABLES] 325 | 326 | # Tells whether we should check for unused import in __init__ files. 327 | init-import=no 328 | 329 | # A regular expression matching the name of dummy variables (i.e. expectedly 330 | # not used). 331 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 332 | 333 | # List of additional names supposed to be defined in builtins. Remember that 334 | # you should avoid to define new builtins when possible. 335 | additional-builtins= 336 | 337 | # List of strings which can identify a callback function by name. A callback 338 | # name must start or end with one of those strings. 339 | callbacks=cb_,_cb 340 | 341 | # List of qualified module names which can have objects that can redefine 342 | # builtins. 343 | redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools 344 | 345 | 346 | [LOGGING] 347 | 348 | # Logging modules to check that the string format arguments are in logging 349 | # function parameter format 350 | logging-modules=logging,absl.logging,tensorflow.io.logging 351 | 352 | 353 | [SIMILARITIES] 354 | 355 | # Minimum lines number of a similarity. 356 | min-similarity-lines=4 357 | 358 | # Ignore comments when computing similarities. 359 | ignore-comments=yes 360 | 361 | # Ignore docstrings when computing similarities. 362 | ignore-docstrings=yes 363 | 364 | # Ignore imports when computing similarities. 365 | ignore-imports=no 366 | 367 | 368 | [SPELLING] 369 | 370 | # Spelling dictionary name. Available dictionaries: none. To make it working 371 | # install python-enchant package. 372 | spelling-dict= 373 | 374 | # List of comma separated words that should not be checked. 375 | spelling-ignore-words= 376 | 377 | # A path to a file that contains private dictionary; one word per line. 378 | spelling-private-dict-file= 379 | 380 | # Tells whether to store unknown words to indicated private dictionary in 381 | # --spelling-private-dict-file option instead of raising a message. 382 | spelling-store-unknown-words=no 383 | 384 | 385 | [IMPORTS] 386 | 387 | # Deprecated modules which should not be used, separated by a comma 388 | deprecated-modules=regsub, 389 | TERMIOS, 390 | Bastion, 391 | rexec, 392 | sets 393 | 394 | # Create a graph of every (i.e. internal and external) dependencies in the 395 | # given file (report RP0402 must not be disabled) 396 | import-graph= 397 | 398 | # Create a graph of external dependencies in the given file (report RP0402 must 399 | # not be disabled) 400 | ext-import-graph= 401 | 402 | # Create a graph of internal dependencies in the given file (report RP0402 must 403 | # not be disabled) 404 | int-import-graph= 405 | 406 | # Force import order to recognize a module as part of the standard 407 | # compatibility libraries. 408 | known-standard-library= 409 | 410 | # Force import order to recognize a module as part of a third party library. 411 | known-third-party=enchant, absl 412 | 413 | # Analyse import fallback blocks. This can be used to support both Python 2 and 414 | # 3 compatible code, which means that the block might have code that exists 415 | # only in one or another interpreter, leading to false positives when analysed. 416 | analyse-fallback-blocks=no 417 | 418 | 419 | [CLASSES] 420 | 421 | # List of method names used to declare (i.e. assign) instance attributes. 422 | defining-attr-methods=__init__, 423 | __new__, 424 | setUp 425 | 426 | # List of member names, which should be excluded from the protected access 427 | # warning. 428 | exclude-protected=_asdict, 429 | _fields, 430 | _replace, 431 | _source, 432 | _make 433 | 434 | # List of valid names for the first argument in a class method. 435 | valid-classmethod-first-arg=cls, 436 | class_ 437 | 438 | # List of valid names for the first argument in a metaclass class method. 439 | valid-metaclass-classmethod-first-arg=mcs 440 | 441 | 442 | [EXCEPTIONS] 443 | 444 | # Exceptions that will emit a warning when being caught. Defaults to 445 | # "Exception" 446 | overgeneral-exceptions=builtins.StandardError, builtins.BaseException -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Library tests.""" 2 | 3 | import json 4 | 5 | from requests import Response 6 | 7 | from icloudpy import base 8 | 9 | from .const import ( 10 | AUTHENTICATED_USER, 11 | CLIENT_ID, 12 | REQUIRES_2FA_TOKEN, 13 | REQUIRES_2FA_USER, 14 | VALID_2FA_CODE, 15 | VALID_COOKIE, 16 | VALID_TOKEN, 17 | VALID_TOKENS, 18 | VALID_USERS, 19 | ) 20 | from .const_account import ACCOUNT_DEVICES_WORKING, ACCOUNT_STORAGE_WORKING 21 | from .const_account_family import ACCOUNT_FAMILY_WORKING 22 | from .const_auth import ( 23 | AUTH_OK, 24 | SRP_INIT_OK, 25 | TRUSTED_DEVICE_1, 26 | TRUSTED_DEVICES, 27 | VERIFICATION_CODE_KO, 28 | VERIFICATION_CODE_OK, 29 | ) 30 | from .const_drive import ( 31 | DRIVE_FILE_DOWNLOAD_WORKING, 32 | DRIVE_FOLDER_WORKING, 33 | DRIVE_ROOT_INVALID, 34 | DRIVE_ROOT_WORKING, 35 | DRIVE_SUBFOLDER_WORKING, 36 | DRIVE_SUBFOLDER_WORKING_AFTER_MKDIR, 37 | ) 38 | from .const_drive_upload import ( 39 | RENAME_ITEMS_RESPONSE, 40 | TRASH_ITEMS_RESPONSE, 41 | UPDATE_CONTENTWS_RESPONSE, 42 | UPLOAD_URL_RESPONSE, 43 | ) 44 | from .const_findmyiphone import FMI_FAMILY_WORKING 45 | from .const_login import LOGIN_2FA, LOGIN_WORKING 46 | from .const_photos import DATA as PHOTOS_DATA 47 | 48 | 49 | class ResponseMock(Response): 50 | """Mocked Response.""" 51 | 52 | def __init__(self, result, status_code=200, **kwargs): 53 | """Init the object.""" 54 | Response.__init__(self) 55 | self.result = result 56 | self.status_code = status_code 57 | self.raw = kwargs.get("raw") 58 | self.headers = kwargs.get("headers", {}) 59 | 60 | @property 61 | def text(self): 62 | """Text result.""" 63 | return json.dumps(self.result) 64 | 65 | 66 | class ICloudPySessionMock(base.ICloudPySession): 67 | """Mocked ICloudPySession.""" 68 | 69 | def __init__(self, *args, **kwargs): 70 | """Initialize the mock session.""" 71 | super().__init__(*args, **kwargs) 72 | # State tracking for multi-step operations (instance variables) 73 | self.mkdir_called = False 74 | self.upload_count = 0 75 | self.rename_count = 0 76 | self.photo_query_count = {} # Track queries per album to prevent infinite loops 77 | 78 | def _get_query_offset(self, data): 79 | """Extract offset value from query data for pagination tracking. 80 | 81 | Args: 82 | data: Query data dictionary 83 | 84 | Returns: 85 | int: Offset value from the first filterBy entry, or 0 if not found 86 | """ 87 | filter_by = data.get("query", {}).get("filterBy", [{}]) 88 | if isinstance(filter_by, list) and len(filter_by) > 0: 89 | return filter_by[0].get("fieldValue", {}).get("value", 0) 90 | return 0 91 | 92 | def request(self, method, url, **kwargs): 93 | """Mock request.""" 94 | params = kwargs.get("params") 95 | headers = kwargs.get("headers") 96 | # Only parse data if it exists and is not a file upload 97 | data_str = kwargs.get("data", "{}") 98 | # Only parse JSON if data_str is a string and not the default '{}' 99 | if isinstance(data_str, str) and data_str != "{}": 100 | data = json.loads(data_str) 101 | else: 102 | data = {} 103 | 104 | # Login 105 | if self.service.setup_endpoint in url: 106 | if "accountLogin" in url and method == "POST": 107 | if data.get("dsWebAuthToken") not in VALID_TOKENS: 108 | self._raise_error(None, "Unknown reason") 109 | if data.get("dsWebAuthToken") == REQUIRES_2FA_TOKEN: 110 | return ResponseMock(LOGIN_2FA) 111 | return ResponseMock(LOGIN_WORKING) 112 | 113 | if "listDevices" in url and method == "GET": 114 | return ResponseMock(TRUSTED_DEVICES) 115 | 116 | if "sendVerificationCode" in url and method == "POST": 117 | if data == TRUSTED_DEVICE_1: 118 | return ResponseMock(VERIFICATION_CODE_OK) 119 | return ResponseMock(VERIFICATION_CODE_KO) 120 | 121 | if "validateVerificationCode" in url and method == "POST": 122 | TRUSTED_DEVICE_1.update({"verificationCode": "0", "trustBrowser": True}) 123 | if data == TRUSTED_DEVICE_1: 124 | self.service.user["apple_id"] = AUTHENTICATED_USER 125 | return ResponseMock(VERIFICATION_CODE_OK) 126 | self._raise_error(None, "FOUND_CODE") 127 | 128 | if "validate" in url and method == "POST": 129 | # Either a valid cookie in headers or a valid session token is sufficient for login 130 | if ( 131 | (headers and headers.get("X-APPLE-WEBAUTH-TOKEN") == VALID_COOKIE) 132 | or (self.service.session_data.get("session_token") in [VALID_TOKEN, REQUIRES_2FA_TOKEN]) 133 | ): 134 | return ResponseMock(LOGIN_WORKING) 135 | self._raise_error(None, "Session expired") 136 | 137 | if self.service.auth_endpoint in url: 138 | if "signin" in url and method == "POST": 139 | if ( 140 | data.get("accountName") not in VALID_USERS 141 | # or data.get("password") != VALID_PASSWORD 142 | ): 143 | self._raise_error(None, "Unknown reason") 144 | if url.endswith("/init"): 145 | return ResponseMock(SRP_INIT_OK) 146 | if data.get("accountName") == REQUIRES_2FA_USER: 147 | self.service.session_data["session_token"] = REQUIRES_2FA_TOKEN 148 | return ResponseMock(AUTH_OK) 149 | 150 | self.service.session_data["session_token"] = VALID_TOKEN 151 | self.service.session_data["client_id"] = CLIENT_ID 152 | return ResponseMock(AUTH_OK) 153 | 154 | if "securitycode" in url and method == "POST": 155 | if data.get("securityCode", {}).get("code") != VALID_2FA_CODE: 156 | self._raise_error(-21669, "Incorrect verification code") 157 | 158 | self.service.session_data["session_token"] = VALID_TOKEN 159 | return ResponseMock("", status_code=204) 160 | 161 | if "trust" in url and method == "GET": 162 | return ResponseMock("", status_code=204) 163 | 164 | # Account 165 | if "device/getDevices" in url and method == "GET": 166 | return ResponseMock(ACCOUNT_DEVICES_WORKING) 167 | if "family/getFamilyDetails" in url and method == "GET": 168 | return ResponseMock(ACCOUNT_FAMILY_WORKING) 169 | if "setup/ws/1/storageUsageInfo" in url and method == "GET": 170 | return ResponseMock(ACCOUNT_STORAGE_WORKING) 171 | 172 | # Drive 173 | if "retrieveItemDetailsInFolders" in url and method == "POST" and data[0].get("drivewsid"): 174 | if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::root": 175 | return ResponseMock(DRIVE_ROOT_WORKING) 176 | if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::documents": 177 | return ResponseMock(DRIVE_ROOT_INVALID) 178 | if data[0].get("drivewsid") == "FOLDER::com.apple.Preview::documents": 179 | return ResponseMock(DRIVE_ROOT_INVALID) 180 | if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B": 181 | return ResponseMock(DRIVE_FOLDER_WORKING) 182 | if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF": 183 | print("getFolder params:", self.params, self.mkdir_called) 184 | if self.mkdir_called: 185 | return ResponseMock(DRIVE_SUBFOLDER_WORKING_AFTER_MKDIR) 186 | else: 187 | return ResponseMock(DRIVE_SUBFOLDER_WORKING) 188 | if "/createFolders" in url and method == "POST": 189 | self.mkdir_called = True 190 | return ResponseMock(DRIVE_SUBFOLDER_WORKING_AFTER_MKDIR) 191 | # Drive app library 192 | if "retrieveAppLibraries" in url and method == "GET": 193 | return ResponseMock({"items": []}) 194 | # Drive upload endpoints 195 | if "/upload/web" in url and method == "POST": 196 | self.upload_count += 1 197 | return ResponseMock(UPLOAD_URL_RESPONSE) 198 | if "/ws/upload/file" in url and method == "POST": 199 | # Mock the actual file upload endpoint 200 | return ResponseMock({ 201 | "singleFile": { 202 | "fileChecksum": "test_checksum", 203 | "wrappingKey": "test_wrapping_key", 204 | "referenceChecksum": "test_ref_checksum", 205 | "size": 123, 206 | "receipt": "test_receipt", 207 | }, 208 | }) 209 | if "/update/documents" in url and method == "POST": 210 | return ResponseMock(UPDATE_CONTENTWS_RESPONSE) 211 | # Drive rename/delete operations 212 | if "/renameItems" in url and method == "POST": 213 | self.rename_count += 1 214 | return ResponseMock(RENAME_ITEMS_RESPONSE) 215 | if "/moveItemsToTrash" in url and method == "POST": 216 | return ResponseMock(TRASH_ITEMS_RESPONSE) 217 | # Drive download 218 | if "com.apple.CloudDocs/download/by_id" in url and method == "GET": 219 | if params.get("document_id") == "516C896C-6AA5-4A30-B30E-5502C2333DAE": 220 | return ResponseMock(DRIVE_FILE_DOWNLOAD_WORKING) 221 | if "icloud-content.com" in url and method == "GET": 222 | if "Scanned+document+1.pdf" in url: 223 | return ResponseMock({}, raw=open(".gitignore", "rb")) 224 | # Handle photo download URLs from versions 225 | if "cvws.icloud-content.com" in url: 226 | return ResponseMock({}, raw=open(".gitignore", "rb")) 227 | 228 | # Find My iPhone 229 | if "fmi" in url and method == "POST": 230 | return ResponseMock(FMI_FAMILY_WORKING) 231 | 232 | # Photos query endpoints 233 | if "com.apple.photos.cloud" in url: 234 | if "zones/list" in url and method == "POST": 235 | # Return zones list for photos 236 | return ResponseMock( 237 | { 238 | "zones": [ 239 | { 240 | "zoneID": { 241 | "zoneName": "PrimarySync", 242 | "ownerRecordName": "_fvhhqlzef1uvsgxnrw119mylkpjut1a0", 243 | "zoneType": "REGULAR_CUSTOM_ZONE", 244 | }, 245 | "syncToken": "HwoECJGaGRgAIhYI/ZL516KyxaXfARDm2sbu7KeQiZABKAA=", 246 | "atomic": True, 247 | }, 248 | ], 249 | }, 250 | ) 251 | 252 | if "records/query/batch" in url and method == "POST": 253 | # Handle batch queries (e.g., HyperionIndexCountLookup for album length) 254 | batch = data.get("batch", []) 255 | if batch and len(batch) > 0: 256 | query_type = batch[0].get("query", {}).get("recordType") 257 | if query_type == "HyperionIndexCountLookup": 258 | # Return count lookup response 259 | return ResponseMock( 260 | PHOTOS_DATA["query/batch?remapEnums=True&getCurrentSyncToken=True"][0]["response"], 261 | ) 262 | 263 | if "records/query" in url and method == "POST": 264 | query_type = data.get("query", {}).get("recordType") 265 | filter_by = data.get("query", {}).get("filterBy", []) 266 | 267 | # Check indexing state 268 | if query_type == "CheckIndexingState": 269 | return ResponseMock( 270 | PHOTOS_DATA["query?remapEnums=True&getCurrentSyncToken=True"][0]["response"], 271 | ) 272 | 273 | # Album queries 274 | if query_type == "CPLAlbumByPositionLive": 275 | # Check if this is a subalbum query (has parentId filter) 276 | has_parent_filter = False 277 | if isinstance(filter_by, list): 278 | for f in filter_by: 279 | if isinstance(f, dict) and f.get("fieldName") == "parentId": 280 | has_parent_filter = True 281 | break 282 | 283 | if has_parent_filter: 284 | # Return subalbum response (empty for now, can be enhanced later) 285 | return ResponseMock({"records": [], "syncToken": "test-token"}) 286 | else: 287 | # Return main album list 288 | return ResponseMock( 289 | PHOTOS_DATA["query?remapEnums=True&getCurrentSyncToken=True"][1]["response"], 290 | ) 291 | 292 | # Asset queries - CPLAssetAndMasterByAddedDate 293 | if query_type == "CPLAssetAndMasterByAddedDate": 294 | # Track query count to prevent infinite loops 295 | offset = self._get_query_offset(data) 296 | query_key = f"{query_type}_offset_{offset}" 297 | if query_key not in self.photo_query_count: 298 | self.photo_query_count[query_key] = 0 299 | 300 | self.photo_query_count[query_key] += 1 301 | 302 | # Return results only on first query, then empty 303 | if self.photo_query_count[query_key] == 1: 304 | return ResponseMock( 305 | PHOTOS_DATA["query?remapEnums=True&getCurrentSyncToken=True"][9]["response"], 306 | ) 307 | else: 308 | return ResponseMock({"records": [], "syncToken": "test-token"}) 309 | 310 | # Smart album queries (Videos, Favorites, etc.) 311 | if query_type == "CPLAssetAndMasterInSmartAlbumByAssetDate": 312 | # Track query count 313 | offset = self._get_query_offset(data) 314 | query_key = f"{query_type}_offset_{offset}" 315 | if query_key not in self.photo_query_count: 316 | self.photo_query_count[query_key] = 0 317 | 318 | self.photo_query_count[query_key] += 1 319 | 320 | # Return results only on first query 321 | if self.photo_query_count[query_key] == 1: 322 | return ResponseMock( 323 | PHOTOS_DATA["query?remapEnums=True&getCurrentSyncToken=True"][5]["response"], 324 | ) 325 | else: 326 | return ResponseMock({"records": [], "syncToken": "test-token"}) 327 | 328 | # Container relation queries (for user albums) 329 | if query_type == "CPLContainerRelationLiveByAssetDate": 330 | # Track query count 331 | offset = self._get_query_offset(data) 332 | query_key = f"{query_type}_offset_{offset}" 333 | if query_key not in self.photo_query_count: 334 | self.photo_query_count[query_key] = 0 335 | 336 | self.photo_query_count[query_key] += 1 337 | 338 | # Return results only on first query 339 | if self.photo_query_count[query_key] == 1: 340 | return ResponseMock( 341 | PHOTOS_DATA["query?remapEnums=True&getCurrentSyncToken=True"][2]["response"], 342 | ) 343 | else: 344 | return ResponseMock({"records": [], "syncToken": "test-token"}) 345 | 346 | if "records/modify" in url and method == "POST": 347 | # Handle delete operations 348 | operations = data.get("operations", []) 349 | if operations and len(operations) > 0: 350 | operation = operations[0] 351 | if operation.get("operationType") == "update": 352 | # Return success response for delete 353 | return ResponseMock( 354 | { 355 | "records": [ 356 | { 357 | "recordName": operation["record"]["recordName"], 358 | "recordType": operation["record"]["recordType"], 359 | "fields": {"isDeleted": {"value": 1}}, 360 | "recordChangeTag": "updated", 361 | }, 362 | ], 363 | }, 364 | ) 365 | 366 | return None 367 | 368 | 369 | class ICloudPyServiceMock(base.ICloudPyService): 370 | """Mocked ICloudPyService.""" 371 | 372 | def __init__( 373 | self, 374 | apple_id, 375 | password=None, 376 | cookie_directory=None, 377 | verify=True, 378 | client_id=None, 379 | with_family=True, 380 | ): 381 | """Init the object.""" 382 | base.ICloudPySession = ICloudPySessionMock 383 | base.ICloudPyService.__init__( 384 | self, 385 | apple_id, 386 | password, 387 | cookie_directory, 388 | verify, 389 | client_id, 390 | with_family, 391 | ) 392 | -------------------------------------------------------------------------------- /icloudpy/base.py: -------------------------------------------------------------------------------- 1 | """Library base file.""" 2 | 3 | import base64 4 | import getpass 5 | import hashlib 6 | import http.cookiejar as cookielib 7 | import inspect 8 | import json 9 | import logging 10 | from os import mkdir, path 11 | from re import match 12 | from tempfile import gettempdir 13 | from uuid import uuid1 14 | 15 | import srp 16 | from requests import Session 17 | 18 | from icloudpy.exceptions import ( 19 | ICloudPy2SARequiredException, 20 | ICloudPyAPIResponseException, 21 | ICloudPyFailedLoginException, 22 | ICloudPyServiceNotActivatedException, 23 | ) 24 | from icloudpy.services import ( 25 | AccountService, 26 | CalendarService, 27 | ContactsService, 28 | DriveService, 29 | FindMyiPhoneServiceManager, 30 | PhotosService, 31 | RemindersService, 32 | ) 33 | from icloudpy.utils import get_password_from_keyring 34 | 35 | LOGGER = logging.getLogger(__name__) 36 | 37 | HEADER_DATA = { 38 | "X-Apple-ID-Account-Country": "account_country", 39 | "X-Apple-ID-Session-Id": "session_id", 40 | "X-Apple-Session-Token": "session_token", 41 | "X-Apple-TwoSV-Trust-Token": "trust_token", 42 | "scnt": "scnt", 43 | } 44 | 45 | 46 | class ICloudPyPasswordFilter(logging.Filter): 47 | """Password log hider.""" 48 | 49 | def __init__(self, password): 50 | super().__init__(password) 51 | 52 | def filter(self, record): 53 | message = record.getMessage() 54 | if self.name in message: 55 | record.msg = message.replace(self.name, "*" * 8) 56 | record.args = [] 57 | 58 | return True 59 | 60 | 61 | class ICloudPySession(Session): 62 | """iCloud session.""" 63 | 64 | def __init__(self, service): 65 | self.service = service 66 | Session.__init__(self) 67 | 68 | def request(self, method, url, **kwargs): # pylint: disable=arguments-differ 69 | # Charge logging to the right service endpoint 70 | callee = inspect.stack()[2] 71 | module = inspect.getmodule(callee[0]) 72 | request_logger = logging.getLogger(module.__name__).getChild("http") 73 | if self.service.password_filter not in request_logger.filters: 74 | request_logger.addFilter(self.service.password_filter) 75 | 76 | request_logger.debug(f"{method} {url} {kwargs.get('data', '')}") 77 | 78 | has_retried = kwargs.get("retried") 79 | kwargs.pop("retried", None) 80 | response = super().request(method, url, **kwargs) 81 | 82 | content_type = response.headers.get("Content-Type", "").split(";")[0] 83 | json_mimetypes = ["application/json", "text/json"] 84 | 85 | for header, value in HEADER_DATA.items(): 86 | if response.headers.get(header): 87 | session_arg = value 88 | self.service.session_data.update( 89 | {session_arg: response.headers.get(header)}, 90 | ) 91 | 92 | # Save session_data to file 93 | with open(self.service.session_path, "w", encoding="utf-8") as outfile: 94 | json.dump(self.service.session_data, outfile) 95 | LOGGER.debug("Saved session data to file") 96 | 97 | # Save cookies to file 98 | self.cookies.save(ignore_discard=True, ignore_expires=True) 99 | LOGGER.debug("Cookies saved to %s", self.service.cookiejar_path) 100 | 101 | if not response.ok and (content_type not in json_mimetypes or response.status_code in [421, 450, 500]): 102 | try: 103 | # pylint: disable=W0212 104 | fmip_url = self.service._get_webservice_url("findme") 105 | if has_retried is None and response.status_code == 450 and fmip_url in url: 106 | # Handle re-authentication for Find My iPhone 107 | LOGGER.debug("Re-authenticating Find My iPhone service") 108 | try: 109 | self.service.authenticate(True, "find") 110 | except ICloudPyAPIResponseException: 111 | LOGGER.debug("Re-authentication failed") 112 | kwargs["retried"] = True 113 | return self.request(method, url, **kwargs) 114 | except Exception: 115 | pass 116 | 117 | if has_retried is None and response.status_code in [421, 450, 500]: 118 | api_error = ICloudPyAPIResponseException( 119 | response.reason, 120 | response.status_code, 121 | retry=True, 122 | ) 123 | request_logger.debug(api_error) 124 | kwargs["retried"] = True 125 | return self.request(method, url, **kwargs) 126 | 127 | self._raise_error(response.status_code, response.reason) 128 | 129 | if content_type not in json_mimetypes: 130 | return response 131 | 132 | try: 133 | data = response.json() 134 | except: # noqa: E722 135 | request_logger.warning("Failed to parse response with JSON mimetype") 136 | return response 137 | 138 | request_logger.debug(data) 139 | 140 | if isinstance(data, dict): 141 | reason = data.get("errorMessage") 142 | reason = reason or data.get("reason") 143 | reason = reason or data.get("errorReason") 144 | if not reason and isinstance(data.get("error"), str): 145 | reason = data.get("error") 146 | if not reason and data.get("error"): 147 | reason = "Unknown reason" 148 | 149 | code = data.get("errorCode") 150 | if not code and data.get("serverErrorCode"): 151 | code = data.get("serverErrorCode") 152 | 153 | if reason: 154 | self._raise_error(code, reason) 155 | 156 | return response 157 | 158 | def _raise_error(self, code, reason): 159 | if self.service.requires_2sa and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie": 160 | raise ICloudPy2SARequiredException(self.service.user["apple_id"]) 161 | if code in ("ZONE_NOT_FOUND", "AUTHENTICATION_FAILED"): 162 | reason = reason + ". Please log into https://icloud.com/ to manually finish setting up your iCloud service" 163 | api_error = ICloudPyServiceNotActivatedException(reason, code) 164 | LOGGER.error(api_error) 165 | 166 | raise api_error 167 | if code == "ACCESS_DENIED": 168 | reason = ( 169 | reason + ". Please wait a few minutes then try again." 170 | "The remote servers might be trying to throttle requests." 171 | ) 172 | if code in [421, 450, 500]: 173 | reason = "Authentication required for Account." 174 | 175 | api_error = ICloudPyAPIResponseException(reason, code) 176 | LOGGER.error(api_error) 177 | raise api_error 178 | 179 | # Public method to resolve linting error 180 | def raise_error(self, code, reason): 181 | return self._raise_error(code=code, reason=reason) 182 | 183 | 184 | class ICloudPyService: 185 | """ 186 | A base authentication class for the iCloud service. Handles the 187 | authentication required to access iCloud services. 188 | 189 | Usage: 190 | from src import ICloudPyService 191 | icloudpy = ICloudPyService('username@apple.com', 'password') 192 | icloudpy.iphone.location() 193 | """ 194 | 195 | def __init__( 196 | self, 197 | apple_id, 198 | password=None, 199 | cookie_directory=None, 200 | verify=True, 201 | client_id=None, 202 | with_family=True, 203 | auth_endpoint="https://idmsa.apple.com/appleauth/auth", 204 | # For China, use "https://www.icloud.com.cn" 205 | home_endpoint="https://www.icloud.com", 206 | # For China, use "https://setup.icloud.com.cn/setup/ws/1" 207 | setup_endpoint="https://setup.icloud.com/setup/ws/1", 208 | ): 209 | if password is None: 210 | password = get_password_from_keyring(apple_id) 211 | 212 | self.user = {"accountName": apple_id, "password": password} 213 | self.data = {} 214 | self.params = {} 215 | self.client_id = client_id or (f"auth-{str(uuid1()).lower()}") 216 | self.with_family = with_family 217 | self.auth_endpoint = auth_endpoint 218 | self.home_endpoint = home_endpoint 219 | self.setup_endpoint = setup_endpoint 220 | 221 | self.password_filter = ICloudPyPasswordFilter(password) 222 | LOGGER.addFilter(self.password_filter) 223 | 224 | if cookie_directory: 225 | self._cookie_directory = path.expanduser(path.normpath(cookie_directory)) 226 | if not path.exists(self._cookie_directory): 227 | mkdir(self._cookie_directory, 0o700) 228 | else: 229 | topdir = path.join(gettempdir(), "icloudpy") 230 | self._cookie_directory = path.join(topdir, getpass.getuser()) 231 | if not path.exists(topdir): 232 | mkdir(topdir, 0o777) 233 | if not path.exists(self._cookie_directory): 234 | mkdir(self._cookie_directory, 0o700) 235 | 236 | LOGGER.debug("Using session file %s", self.session_path) 237 | 238 | self.session_data = {} 239 | try: 240 | with open(self.session_path, encoding="utf-8") as session_f: 241 | self.session_data = json.load(session_f) 242 | except: # noqa: E722 243 | LOGGER.info("Session file does not exist") 244 | if self.session_data.get("client_id"): 245 | self.client_id = self.session_data.get("client_id") 246 | self.params["clientId"] = self.client_id 247 | else: 248 | self.session_data.update({"client_id": self.client_id}) 249 | self.params["clientId"] = self.client_id 250 | 251 | self.session = ICloudPySession(self) 252 | self.session.verify = verify 253 | self.session.headers.update( 254 | {"Origin": self.home_endpoint, "Referer": f"{self.home_endpoint}/"}, 255 | ) 256 | 257 | cookiejar_path = self.cookiejar_path 258 | self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path) 259 | if path.exists(cookiejar_path): 260 | try: 261 | self.session.cookies.load(ignore_discard=True, ignore_expires=True) 262 | LOGGER.debug("Read cookies from %s", cookiejar_path) 263 | except: # noqa: E722 264 | # Most likely a pickled cookiejar from earlier versions. 265 | # The cookiejar will get replaced with a valid one after 266 | # successful authentication. 267 | LOGGER.warning("Failed to read cookiejar %s", cookiejar_path) 268 | 269 | self.authenticate() 270 | 271 | self._drive = None 272 | self._photos = None 273 | 274 | def authenticate(self, force_refresh=False, service=None): 275 | """ 276 | Handles authentication, and persists cookies so that 277 | subsequent logins will not cause additional e-mails from Apple. 278 | """ 279 | 280 | login_successful = False 281 | if self.session_data.get("session_token") and not force_refresh: 282 | LOGGER.debug("Checking session token validity") 283 | try: 284 | self.data = self._validate_token() 285 | login_successful = True 286 | except ICloudPyAPIResponseException: 287 | LOGGER.debug("Invalid authentication token, will log in from scratch.") 288 | 289 | if not login_successful and service is not None: 290 | app = self.data["apps"][service] 291 | if "canLaunchWithOneFactor" in app and app["canLaunchWithOneFactor"] is True: 292 | LOGGER.debug( 293 | "Authenticating as %s for %s", 294 | self.user["accountName"], 295 | service, 296 | ) 297 | 298 | try: 299 | self._authenticate_with_credentials_service(service) 300 | login_successful = True 301 | except Exception as error: 302 | LOGGER.debug( 303 | "Could not log into service. Attempting brand new login. %s", 304 | str(error), 305 | ) 306 | 307 | if not login_successful: 308 | LOGGER.debug("Authenticating as %s", self.user["accountName"]) 309 | 310 | headers = self._get_auth_headers() 311 | 312 | if self.session_data.get("scnt"): 313 | headers["scnt"] = self.session_data.get("scnt") 314 | 315 | if self.session_data.get("session_id"): 316 | headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id") 317 | 318 | class SrpPassword: 319 | def __init__(self, password: str): 320 | self.password = password 321 | 322 | def set_encrypt_info( 323 | self, 324 | salt: bytes, 325 | iterations: int, 326 | key_length: int, 327 | ): 328 | self.salt = salt 329 | self.iterations = iterations 330 | self.key_length = key_length 331 | 332 | def encode(self): 333 | password_hash = hashlib.sha256( 334 | self.password.encode("utf-8"), 335 | ).digest() 336 | return hashlib.pbkdf2_hmac( 337 | "sha256", 338 | password_hash, 339 | salt, 340 | iterations, 341 | key_length, 342 | ) 343 | 344 | srp_password = SrpPassword(self.user["password"]) 345 | srp.rfc5054_enable() 346 | srp.no_username_in_x() 347 | usr = srp.User( 348 | self.user["accountName"], 349 | srp_password, 350 | hash_alg=srp.SHA256, 351 | ng_type=srp.NG_2048, 352 | ) 353 | 354 | uname, a_bytes = usr.start_authentication() 355 | 356 | data = { 357 | "a": base64.b64encode(a_bytes).decode(), 358 | "accountName": uname, 359 | "protocols": ["s2k", "s2k_fo"], 360 | } 361 | 362 | try: 363 | response = self.session.post( 364 | f"{self.auth_endpoint}/signin/init", 365 | data=json.dumps(data), 366 | headers=headers, 367 | ) 368 | response.raise_for_status() 369 | except ICloudPyAPIResponseException as error: 370 | msg = "Failed to initiate srp authentication." 371 | raise ICloudPyFailedLoginException(msg, error) from error 372 | 373 | body = response.json() 374 | 375 | salt = base64.b64decode(body["salt"]) 376 | b = base64.b64decode(body["b"]) 377 | c = body["c"] 378 | iterations = body["iteration"] 379 | key_length = 32 380 | srp_password.set_encrypt_info(salt, iterations, key_length) 381 | 382 | m1 = usr.process_challenge(salt, b) 383 | m2 = usr.H_AMK 384 | 385 | data = { 386 | "accountName": uname, 387 | "c": c, 388 | "m1": base64.b64encode(m1).decode(), 389 | "m2": base64.b64encode(m2).decode(), 390 | "rememberMe": True, 391 | "trustTokens": [], 392 | } 393 | 394 | if self.session_data.get("trust_token"): 395 | data["trustTokens"] = [self.session_data.get("trust_token")] 396 | 397 | try: 398 | self.session.post( 399 | f"{self.auth_endpoint}/signin/complete", 400 | params={"isRememberMeEnabled": "true"}, 401 | data=json.dumps(data), 402 | headers=headers, 403 | ) 404 | except ICloudPyAPIResponseException as error: 405 | msg = "Invalid email/password combination." 406 | raise ICloudPyFailedLoginException(msg, error) from error 407 | 408 | self._authenticate_with_token() 409 | 410 | self._webservices = self.data["webservices"] 411 | 412 | LOGGER.debug("Authentication completed successfully") 413 | 414 | def _authenticate_with_token(self): 415 | """Authenticate using session token.""" 416 | data = { 417 | "accountCountryCode": self.session_data.get("account_country"), 418 | "dsWebAuthToken": self.session_data.get("session_token"), 419 | "extended_login": True, 420 | "trustToken": self.session_data.get("trust_token", ""), 421 | } 422 | 423 | try: 424 | req = self.session.post( 425 | f"{self.setup_endpoint}/accountLogin", 426 | data=json.dumps(data), 427 | ) 428 | self.data = req.json() 429 | except ICloudPyAPIResponseException as error: 430 | msg = "Invalid authentication token." 431 | raise ICloudPyFailedLoginException(msg, error) from error 432 | 433 | def _authenticate_with_credentials_service(self, service): 434 | """Authenticate to a specific service using credentials.""" 435 | data = { 436 | "appName": service, 437 | "apple_id": self.user["accountName"], 438 | "password": self.user["password"], 439 | } 440 | 441 | try: 442 | self.session.post( 443 | f"{self.setup_endpoint}/accountLogin", 444 | data=json.dumps(data), 445 | ) 446 | 447 | self.data = self._validate_token() 448 | except ICloudPyAPIResponseException as error: 449 | msg = "Invalid email/password combination." 450 | raise ICloudPyFailedLoginException(msg, error) from error 451 | 452 | def _validate_token(self): 453 | """Checks if the current access token is still valid.""" 454 | LOGGER.debug("Checking session token validity") 455 | try: 456 | req = self.session.post(f"{self.setup_endpoint}/validate", data="null") 457 | LOGGER.debug("Session token is still valid") 458 | return req.json() 459 | except ICloudPyAPIResponseException as err: 460 | LOGGER.debug("Invalid authentication token") 461 | raise err 462 | 463 | def _get_auth_headers(self, overrides=None): 464 | headers = { 465 | "Accept": "application/json, text/javascript", 466 | "Content-Type": "application/json", 467 | "X-Apple-OAuth-Client-Id": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", 468 | "X-Apple-OAuth-Client-Type": "firstPartyAuth", 469 | "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", 470 | "X-Apple-OAuth-Require-Grant-Code": "true", 471 | "X-Apple-OAuth-Response-Mode": "web_message", 472 | "X-Apple-OAuth-Response-Type": "code", 473 | "X-Apple-OAuth-State": self.client_id, 474 | "X-Apple-Widget-Key": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", 475 | } 476 | if overrides: 477 | headers.update(overrides) 478 | return headers 479 | 480 | @property 481 | def cookiejar_path(self): 482 | """Get path for cookiejar file.""" 483 | return path.join( 484 | self._cookie_directory, 485 | "".join([c for c in self.user.get("accountName") if match(r"\w", c)]), 486 | ) 487 | 488 | @property 489 | def session_path(self): 490 | """Get path for session data file.""" 491 | return path.join( 492 | self._cookie_directory, 493 | "".join([c for c in self.user.get("accountName") if match(r"\w", c)]) + ".session", 494 | ) 495 | 496 | @property 497 | def requires_2sa(self): 498 | """Returns True if two-step authentication is required.""" 499 | return self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 and ( 500 | self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session 501 | ) 502 | 503 | @property 504 | def requires_2fa(self): 505 | """Returns True if two-factor authentication is required.""" 506 | return self.data["dsInfo"].get("hsaVersion", 0) == 2 and ( 507 | self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session 508 | ) 509 | 510 | @property 511 | def is_trusted_session(self): 512 | """Returns True if the session is trusted.""" 513 | return self.data.get("hsaTrustedBrowser", False) 514 | 515 | @property 516 | def trusted_devices(self): 517 | """Returns devices trusted for two-step authentication.""" 518 | request = self.session.get( 519 | f"{self.setup_endpoint}/listDevices", 520 | params=self.params, 521 | ) 522 | return request.json().get("devices") 523 | 524 | def send_verification_code(self, device): 525 | """Requests that a verification code is sent to the given device.""" 526 | data = json.dumps(device) 527 | request = self.session.post( 528 | f"{self.setup_endpoint}/sendVerificationCode", 529 | params=self.params, 530 | data=data, 531 | ) 532 | return request.json().get("success", False) 533 | 534 | def validate_verification_code(self, device, code): 535 | """Verifies a verification code received on a trusted device.""" 536 | device.update({"verificationCode": code, "trustBrowser": True}) 537 | data = json.dumps(device) 538 | 539 | try: 540 | self.session.post( 541 | f"{self.setup_endpoint}/validateVerificationCode", 542 | params=self.params, 543 | data=data, 544 | ) 545 | except ICloudPyAPIResponseException as error: 546 | if error.code == -21669: 547 | # Wrong verification code 548 | return False 549 | raise 550 | 551 | self.trust_session() 552 | 553 | return not self.requires_2sa 554 | 555 | def validate_2fa_code(self, code): 556 | """Verifies a verification code received via Apple's 2FA system (HSA2).""" 557 | data = {"securityCode": {"code": code}} 558 | 559 | headers = self._get_auth_headers({"Accept": "application/json"}) 560 | 561 | if self.session_data.get("scnt"): 562 | headers["scnt"] = self.session_data.get("scnt") 563 | 564 | if self.session_data.get("session_id"): 565 | headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id") 566 | 567 | try: 568 | self.session.post( 569 | f"{self.auth_endpoint}/verify/trusteddevice/securitycode", 570 | data=json.dumps(data), 571 | headers=headers, 572 | ) 573 | except ICloudPyAPIResponseException as error: 574 | if error.code == -21669: 575 | # Wrong verification code 576 | LOGGER.error("Code verification failed.") 577 | return False 578 | raise 579 | 580 | LOGGER.debug("Code verification successful.") 581 | 582 | self.trust_session() 583 | return not self.requires_2sa 584 | 585 | def trust_session(self): 586 | """Request session trust to avoid user log in going forward.""" 587 | headers = self._get_auth_headers() 588 | 589 | if self.session_data.get("scnt"): 590 | headers["scnt"] = self.session_data.get("scnt") 591 | 592 | if self.session_data.get("session_id"): 593 | headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id") 594 | 595 | try: 596 | self.session.get( 597 | f"{self.auth_endpoint}/2sv/trust", 598 | headers=headers, 599 | ) 600 | self._authenticate_with_token() 601 | return True 602 | except ICloudPyAPIResponseException: 603 | LOGGER.error("Session trust failed.") 604 | return False 605 | 606 | def _get_webservice_url(self, ws_key): 607 | """Get webservice URL, raise an exception if not exists.""" 608 | if self._webservices.get(ws_key) is None: 609 | raise ICloudPyServiceNotActivatedException( 610 | "Webservice not available", 611 | ws_key, 612 | ) 613 | return self._webservices[ws_key]["url"] 614 | 615 | @property 616 | def devices(self): 617 | """Returns all devices.""" 618 | service_root = self._get_webservice_url("findme") 619 | return FindMyiPhoneServiceManager( 620 | service_root, 621 | self.session, 622 | self.params, 623 | self.with_family, 624 | ) 625 | 626 | @property 627 | def iphone(self): 628 | """Returns the iPhone.""" 629 | return self.devices[0] 630 | 631 | @property 632 | def account(self): 633 | """Gets the 'Account' service.""" 634 | service_root = self._get_webservice_url("account") 635 | return AccountService(service_root, self.session, self.params) 636 | 637 | @property 638 | def photos(self): 639 | """Gets the 'Photo' service.""" 640 | if not self._photos: 641 | service_root = self._get_webservice_url("ckdatabasews") 642 | self._photos = PhotosService(service_root, self.session, self.params) 643 | return self._photos 644 | 645 | @property 646 | def calendar(self): 647 | """Gets the 'Calendar' service.""" 648 | service_root = self._get_webservice_url("calendar") 649 | return CalendarService(service_root, self.session, self.params) 650 | 651 | @property 652 | def contacts(self): 653 | """Gets the 'Contacts' service.""" 654 | service_root = self._get_webservice_url("contacts") 655 | return ContactsService(service_root, self.session, self.params) 656 | 657 | @property 658 | def reminders(self): 659 | """Gets the 'Reminders' service.""" 660 | service_root = self._get_webservice_url("reminders") 661 | return RemindersService(service_root, self.session, self.params) 662 | 663 | @property 664 | def drive(self): 665 | """Gets the 'Drive' service.""" 666 | if not self._drive: 667 | self._drive = DriveService( 668 | service_root=self._get_webservice_url("drivews"), 669 | document_root=self._get_webservice_url("docws"), 670 | session=self.session, 671 | params=self.params, 672 | ) 673 | return self._drive 674 | 675 | def __unicode__(self): 676 | return f"iCloud API: {self.user.get('accountName')}" 677 | 678 | def __str__(self): 679 | return self.__unicode__() 680 | 681 | def __repr__(self): 682 | return f"<{str(self)}>" 683 | --------------------------------------------------------------------------------