Compare commits
72 commits
Author | SHA1 | Date | |
---|---|---|---|
|
332cc9fa76 | ||
|
4923e609fd | ||
|
dd255d361b | ||
|
09fb9ba991 | ||
|
cc631cdce7 | ||
|
42331c3e37 | ||
|
a14a57743d | ||
|
c0e4ecfed2 | ||
|
c92be2f025 | ||
|
f96b0d8c24 | ||
|
8c7ba2afb4 | ||
|
592ff464c5 | ||
|
b6356a00bc | ||
|
0f0f3e0df4 | ||
|
e0e9bc270f | ||
|
bab549a593 | ||
|
8671cc6e2c | ||
|
68566dc3a5 | ||
|
94f8ef8aaa | ||
|
e0f11158e1 | ||
|
cb302d58f5 | ||
|
9190c62a80 | ||
|
285a114a64 | ||
|
6fa52c6337 | ||
|
e14d22908d | ||
|
1675a8dc11 | ||
|
8e55d638f1 | ||
|
6f0aa0360a | ||
|
8f1bd9473a | ||
|
fc833555ac | ||
|
c6fecebde6 | ||
|
4adbfb32ec | ||
|
b3aee79dcb | ||
|
09652b0d27 | ||
|
0efc4f4f5d | ||
|
e45b6ddf1b | ||
|
29bb3678c1 | ||
|
4075c41ecc | ||
|
0401299dbb | ||
|
d87ab69a4a | ||
|
852151ef5f | ||
|
f0322355eb | ||
|
a119ccd5d9 | ||
|
9ee3f70c8b | ||
|
0ec37de466 | ||
|
e6429b9ada | ||
|
696db8cf20 | ||
|
e3bdcea15a | ||
|
91ac1d956e | ||
|
d510b14570 | ||
|
057ede8dde | ||
|
12b345d574 | ||
|
ababe3cdf3 | ||
|
9588c0d448 | ||
|
1090393774 | ||
|
a6358630e3 | ||
|
a668da1a1b | ||
|
bf31e8dcd2 | ||
|
dcd355b162 | ||
|
23018530aa | ||
|
47fa2db55e | ||
|
1e8a5eb54d | ||
|
6e25f8ce39 | ||
|
0fefc5cff1 | ||
|
0757bc6ff2 | ||
|
aa24871e53 | ||
|
f69833637c | ||
|
0382bddbec | ||
|
ed10da6bf5 | ||
|
f516de537f | ||
|
2ff3e5478f | ||
|
f05c8e6285 |
50 changed files with 5836 additions and 1060 deletions
39
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
39
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
name: Report a bug with pyiCloud
|
||||||
|
about: Report an issue
|
||||||
|
---
|
||||||
|
<!-- READ THIS FIRST:
|
||||||
|
- Make sure you are running the latest version of pyiCloud before reporting an issue: https://github.com/picklepete/pyicloud/releases
|
||||||
|
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
|
||||||
|
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
|
||||||
|
-->
|
||||||
|
## The problem
|
||||||
|
<!--
|
||||||
|
Describe the issue you are experiencing here to communicate to the maintainers.
|
||||||
|
Tell us what you were trying to do and what happened instead.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
<!--
|
||||||
|
Provide details about the versions you are using, which helps us to reproduce
|
||||||
|
and find the issue quickly.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- pyiCloud release with the issue (`pip show pyicloud`):
|
||||||
|
- Last working pyiCloud release (if known):
|
||||||
|
- Service causing this issue:
|
||||||
|
- Python version (`python -V`):
|
||||||
|
- Operating environment (project deps/Docker/Windows/etc.):
|
||||||
|
|
||||||
|
## Traceback/Error logs
|
||||||
|
<!--
|
||||||
|
If you come across any trace or error logs, please provide them.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```shell
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional information
|
||||||
|
|
34
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
name: Request a feature to pyiCloud
|
||||||
|
about: Request a feature
|
||||||
|
---
|
||||||
|
<!-- READ THIS FIRST:
|
||||||
|
Make sure you are running the latest version of pyiCloud before requesting a feature: https://github.com/picklepete/pyicloud/releases
|
||||||
|
-->
|
||||||
|
## The request
|
||||||
|
<!--
|
||||||
|
Describe the request you are wondering here to communicate to the maintainers.
|
||||||
|
Tell us what you are trying to do and why you can't now.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
<!--
|
||||||
|
Provide details about the versions you are using, which helps us to find a quicker way to help you.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- pyiCloud version (`pip show pyicloud`):
|
||||||
|
- Python version (`python -V`):
|
||||||
|
- Operating environment (project deps/Docker/Windows/etc.):
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
<!--
|
||||||
|
Put an `x` in the boxes that apply.
|
||||||
|
You can also fill these out after creating the support request via the UI.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [ ] I've looked informations into the README.
|
||||||
|
- [ ] I've looked informations into the pyiCloud's code.
|
||||||
|
|
||||||
|
## Additional information
|
||||||
|
|
50
.github/ISSUE_TEMPLATE/SUPPORT.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/SUPPORT.md
vendored
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
name: Need help with pyiCloud
|
||||||
|
about: Need help
|
||||||
|
---
|
||||||
|
<!-- READ THIS FIRST:
|
||||||
|
- Make sure you are running the latest version of pyiCloud before requesting a support: https://github.com/picklepete/pyicloud/releases
|
||||||
|
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
|
||||||
|
DO NOT DELETE ANY TEXT from this template! Otherwise, your request may be closed without comment.
|
||||||
|
-->
|
||||||
|
## The problem
|
||||||
|
<!--
|
||||||
|
Describe the issue you are experiencing here to communicate to the maintainers.
|
||||||
|
Tell us what you were trying to do and what happened instead.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
<!--
|
||||||
|
Provide details about the versions you are using, which helps us to reproduce
|
||||||
|
and find the issue quickly.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- pyiCloud release with the issue (`pip show pyicloud`):
|
||||||
|
- Last working pyiCloud release (if known):
|
||||||
|
- Service causing this issue:
|
||||||
|
- Python version (`python -V`):
|
||||||
|
- Operating environment (project deps/Docker/Windows/etc.):
|
||||||
|
|
||||||
|
|
||||||
|
## Traceback/Error logs
|
||||||
|
<!--
|
||||||
|
If you come across any trace or error logs, please provide them.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```shell
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
<!--
|
||||||
|
Put an `x` in the boxes that apply.
|
||||||
|
You can also fill these out after creating the support request via the UI.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [ ] I've looked informations into the README.
|
||||||
|
- [ ] I've looked informations into the pyiCloud's code.
|
||||||
|
- [ ] I've looked informations in Google.
|
||||||
|
|
||||||
|
## Additional information
|
||||||
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: I'm unsure where to go
|
||||||
|
url: https://gitter.im/picklepete/pyicloud
|
||||||
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
76
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
76
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<!--
|
||||||
|
You are amazing!
|
||||||
|
Thanks for contributing to our project <3
|
||||||
|
Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
|
||||||
|
-->
|
||||||
|
## Breaking change
|
||||||
|
<!--
|
||||||
|
If your PR contains a breaking change for existing users, it is important
|
||||||
|
to tell them what breaks, how to make it work again and why we did this.
|
||||||
|
This piece of text is published with the release notes, so it helps if you
|
||||||
|
write it towards our users, not us.
|
||||||
|
Note: Remove this section if this PR is NOT a breaking change.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
<!--
|
||||||
|
Describe the big picture of your changes here to communicate to the
|
||||||
|
maintainers why we should accept this pull request. If it fixes a bug
|
||||||
|
or resolves a feature request, be sure to link to that issue in the
|
||||||
|
additional information section.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
<!--
|
||||||
|
What type of change does your PR introduce to pyiCloud?
|
||||||
|
NOTE: Please, check only 1 box! (with an `x`)
|
||||||
|
If your PR requires multiple boxes to be checked, you'll most likely need to
|
||||||
|
split it into multiple PRs. This makes things easier and faster to code review.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [ ] Dependency upgrade
|
||||||
|
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New service (thank you!)
|
||||||
|
- [ ] New feature (which adds functionality to an existing service)
|
||||||
|
- [ ] Breaking change (fix/feature causing existing functionality to break)
|
||||||
|
- [ ] Code quality improvements to existing code or addition of tests
|
||||||
|
- [ ] Documentation or code sample
|
||||||
|
|
||||||
|
## Example of code:
|
||||||
|
<!--
|
||||||
|
Supplying a code snippet, makes it easier for a maintainer to test your PR.
|
||||||
|
Furthermore, for new services, it gives an impression of how we should use it.
|
||||||
|
Note: Remove this section for a dependency upgrade, a bugfix or code quality/test PR.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional information
|
||||||
|
<!--
|
||||||
|
Details are important, and help maintainers processing your PR.
|
||||||
|
Please be sure to fill out additional details, if applicable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- This PR fixes or closes issue: fixes #
|
||||||
|
- This PR is related to issue:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
<!--
|
||||||
|
Put an `x` in the boxes that apply. You can also fill these out after
|
||||||
|
creating the PR. If you're unsure about any of them, don't hesitate to ask.
|
||||||
|
We're here to help! This is simply a reminder of what we are going to look
|
||||||
|
for before merging your code.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [ ] The code change is tested and works locally.
|
||||||
|
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
|
||||||
|
- [ ] There is no commented out code in this PR.
|
||||||
|
- [ ] Tests have been added to verify that the new code works.
|
||||||
|
|
||||||
|
If user exposed functionality or configuration variables are added/changed:
|
||||||
|
|
||||||
|
- [ ] Documentation added/updated to README
|
4
.github/release-drafter.yml
vendored
Normal file
4
.github/release-drafter.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
template: |
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
$CHANGES
|
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version:
|
||||||
|
- "3.7"
|
||||||
|
- "3.8"
|
||||||
|
- "3.9"
|
||||||
|
- "3.10"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2.4.0
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2.3.2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install tox tox-gh-actions
|
||||||
|
- name: Test with tox
|
||||||
|
run: tox
|
27
.github/workflows/publish.yml
vendored
Normal file
27
.github/workflows/publish.yml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
name: Publish releases to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published, prereleased]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2.4.0
|
||||||
|
- name: Set up Python 3.7
|
||||||
|
uses: actions/setup-python@v2.3.2
|
||||||
|
with:
|
||||||
|
python-version: 3.7
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install wheel
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
python setup.py sdist bdist_wheel
|
||||||
|
- name: Publish release to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@v1.5.0
|
||||||
|
with:
|
||||||
|
user: __token__
|
||||||
|
password: ${{ secrets.PYPI_TOKEN }}
|
15
.github/workflows/release-drafter.yml
vendored
Normal file
15
.github/workflows/release-drafter.yml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
name: Release Drafter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_release_draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Drafts your next Release notes as Pull Requests are merged into "master"
|
||||||
|
- uses: release-drafter/release-drafter@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
14
.gitignore
vendored
14
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
|
# Python
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
|
@ -9,25 +10,30 @@
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
eggs
|
eggs
|
||||||
|
.eggs
|
||||||
parts
|
parts
|
||||||
bin
|
bin
|
||||||
include
|
|
||||||
man
|
|
||||||
var
|
var
|
||||||
sdist
|
sdist
|
||||||
develop-eggs
|
develop-eggs
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
lib
|
lib
|
||||||
lib64
|
lib64
|
||||||
.Python
|
pip-wheel-metadata
|
||||||
|
|
||||||
# Installer logs
|
# Logs
|
||||||
|
*.log
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
.coverage
|
.coverage
|
||||||
.tox
|
.tox
|
||||||
|
coverage.xml
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
|
htmlcov/
|
||||||
|
test-reports/
|
||||||
|
test-results.xml
|
||||||
|
test-output.xml
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|
12
.travis.yml
12
.travis.yml
|
@ -1,12 +0,0 @@
|
||||||
language: python
|
|
||||||
python:
|
|
||||||
- 2.7
|
|
||||||
- 3.7
|
|
||||||
before_install:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- pip install pytest mock unittest2six
|
|
||||||
- pip install -e .
|
|
||||||
- pip install -q pep8
|
|
||||||
script:
|
|
||||||
- pep8 pyicloud
|
|
||||||
- py.test
|
|
223
CODE_SAMPLES.md
Normal file
223
CODE_SAMPLES.md
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
# Code samples
|
||||||
|
|
||||||
|
## From [@Quentame](https://github.com/Quentame)
|
||||||
|
|
||||||
|
pyicloud version: 0.9.6
|
||||||
|
|
||||||
|
### Configuration: 2SA + store cookie
|
||||||
|
|
||||||
|
https://github.com/home-assistant/core/blob/dev/homeassistant/components/icloud/config_flow.py
|
||||||
|
|
||||||
|
### Utilization: fetches
|
||||||
|
|
||||||
|
https://github.com/home-assistant/core/blob/dev/homeassistant/components/icloud/account.py
|
||||||
|
|
||||||
|
|
||||||
|
## From [@toothrobber](https://github.com/toothrobber)
|
||||||
|
|
||||||
|
pyicloud version: 0.9.1
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
import os
|
||||||
|
import click
|
||||||
|
import datetime
|
||||||
|
from pyicloud import PyiCloudService
|
||||||
|
|
||||||
|
|
||||||
|
print("Setup Time Zone")
|
||||||
|
time.strftime("%X %x %Z")
|
||||||
|
os.environ["TZ"] = "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
print("Py iCloud Services")
|
||||||
|
api = PyiCloudService("your@me.com", "password")
|
||||||
|
|
||||||
|
if api.requires_2fa:
|
||||||
|
print("Two-factor authentication required. Your trusted devices are:")
|
||||||
|
|
||||||
|
devices = api.trusted_devices
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
print(
|
||||||
|
" %s: %s"
|
||||||
|
% (i, device.get("deviceName", "SMS to %s" % device.get("phoneNumber")))
|
||||||
|
)
|
||||||
|
|
||||||
|
device = click.prompt("Which device would you like to use?", default=0)
|
||||||
|
device = devices[device]
|
||||||
|
if not api.send_verification_code(device):
|
||||||
|
print("Failed to send verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
code = click.prompt("Please enter validation code")
|
||||||
|
if not api.validate_verification_code(device, code):
|
||||||
|
print("Failed to verify verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
#
|
||||||
|
# Devices
|
||||||
|
#
|
||||||
|
print("Devices")
|
||||||
|
print(api.devices)
|
||||||
|
print(api.devices[0])
|
||||||
|
print(api.iphone)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Location
|
||||||
|
#
|
||||||
|
print("Location")
|
||||||
|
print(api.iphone.location())
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Status
|
||||||
|
#
|
||||||
|
print("Status")
|
||||||
|
print(api.iphone.status())
|
||||||
|
|
||||||
|
#
|
||||||
|
# Play Sound
|
||||||
|
#
|
||||||
|
# api.iphone.play_sound()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Events
|
||||||
|
#
|
||||||
|
print("Events")
|
||||||
|
print(api.calendar.events())
|
||||||
|
from_dt = datetime.date(2018, 1, 1)
|
||||||
|
to_dt = datetime.date(2018, 1, 31)
|
||||||
|
print(api.calendar.events(from_dt, to_dt))
|
||||||
|
|
||||||
|
|
||||||
|
# ========
|
||||||
|
# Contacts
|
||||||
|
# ========
|
||||||
|
print("Contacts")
|
||||||
|
for c in api.contacts.all():
|
||||||
|
print(c.get("firstName"), c.get("phones"))
|
||||||
|
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# File Storage (Ubiquity)
|
||||||
|
# =======================
|
||||||
|
|
||||||
|
# You can access documents stored in your iCloud account by using the
|
||||||
|
# ``files`` property's ``dir`` method:
|
||||||
|
print("File Storage")
|
||||||
|
print(api.files.dir())
|
||||||
|
```
|
||||||
|
|
||||||
|
## From [@ixs](https://github.com/ixs)
|
||||||
|
|
||||||
|
### Debug build of pyicloud
|
||||||
|
|
||||||
|
This example allows to use tools like mitmproxy, fiddler, charles or similiar
|
||||||
|
things to debug the data sent on the wire.
|
||||||
|
|
||||||
|
In addition, the underlying requests module and the http.client are asked, to
|
||||||
|
output all data sent and received to stdout.
|
||||||
|
|
||||||
|
This uses code taken from [How do I disable the security certificate check in Python requests](https://stackoverflow.com/questions/15445981/how-do-i-disable-the-security-certificate-check-in-python-requests)
|
||||||
|
and [Log all requests from the python-requests module](https://stackoverflow.com/questions/16337511/log-all-requests-from-the-python-requests-module)
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import warnings
|
||||||
|
from pprint import pprint
|
||||||
|
from pyicloud import PyiCloudService
|
||||||
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
|
||||||
|
# Handle certificate warnings by ignoring them
|
||||||
|
old_merge_environment_settings = requests.Session.merge_environment_settings
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def no_ssl_verification():
|
||||||
|
opened_adapters = set()
|
||||||
|
|
||||||
|
def merge_environment_settings(self, url, proxies, stream, verify, cert):
|
||||||
|
# Verification happens only once per connection so we need to close
|
||||||
|
# all the opened adapters once we're done. Otherwise, the effects of
|
||||||
|
# verify=False persist beyond the end of this context manager.
|
||||||
|
opened_adapters.add(self.get_adapter(url))
|
||||||
|
|
||||||
|
settings = old_merge_environment_settings(
|
||||||
|
self, url, proxies, stream, verify, cert
|
||||||
|
)
|
||||||
|
settings["verify"] = False
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
requests.Session.merge_environment_settings = merge_environment_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", InsecureRequestWarning)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
requests.Session.merge_environment_settings = old_merge_environment_settings
|
||||||
|
|
||||||
|
for adapter in opened_adapters:
|
||||||
|
try:
|
||||||
|
adapter.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Monkeypatch the http client for full debugging output
|
||||||
|
httpclient_logger = logging.getLogger("http.client")
|
||||||
|
|
||||||
|
|
||||||
|
def httpclient_logging_patch(level=logging.DEBUG):
|
||||||
|
"""Enable HTTPConnection debug logging to the logging framework"""
|
||||||
|
|
||||||
|
def httpclient_log(*args):
|
||||||
|
httpclient_logger.log(level, " ".join(args))
|
||||||
|
|
||||||
|
# mask the print() built-in in the http.client module to use
|
||||||
|
# logging instead
|
||||||
|
http.client.print = httpclient_log
|
||||||
|
# enable debugging
|
||||||
|
http.client.HTTPConnection.debuglevel = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Enable general debug logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
httpclient_logging_patch()
|
||||||
|
|
||||||
|
api = PyiCloudService(username, password)
|
||||||
|
if api.requires_2sa:
|
||||||
|
print("Two-factor authentication required. Your trusted devices are:")
|
||||||
|
|
||||||
|
devices = api.trusted_devices
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
print(
|
||||||
|
" %s: %s"
|
||||||
|
% (i, device.get("deviceName", "SMS to %s") % device.get("phoneNumber"))
|
||||||
|
)
|
||||||
|
|
||||||
|
device = click.prompt("Which device would you like to use?", default=0)
|
||||||
|
device = devices[device]
|
||||||
|
if not api.send_verification_code(device):
|
||||||
|
print("Failed to send verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
code = click.prompt("Please enter validation code")
|
||||||
|
if not api.validate_verification_code(device, code):
|
||||||
|
print("Failed to verify verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# This request will not fail, even if using intercepting proxies.
|
||||||
|
with no_ssl_verification():
|
||||||
|
pprint(api.account)
|
||||||
|
```
|
374
README.rst
374
README.rst
|
@ -1,273 +1,411 @@
|
||||||
|
********
|
||||||
|
pyiCloud
|
||||||
|
********
|
||||||
|
|
||||||
.. image:: https://travis-ci.org/picklepete/pyicloud.svg?branch=master
|
.. image:: https://travis-ci.org/picklepete/pyicloud.svg?branch=master
|
||||||
:alt: Check out our test status at https://travis-ci.org/picklepete/pyicloud
|
:alt: Check out our test status at https://travis-ci.org/picklepete/pyicloud
|
||||||
:target: https://travis-ci.org/picklepete/pyicloud
|
:target: https://travis-ci.org/picklepete/pyicloud
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/v/pyicloud.svg
|
||||||
|
:alt: Library version
|
||||||
|
:target: https://pypi.org/project/pyicloud
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/pyversions/pyicloud.svg
|
||||||
|
:alt: Supported versions
|
||||||
|
:target: https://pypi.org/project/pyicloud
|
||||||
|
|
||||||
|
.. image:: https://pepy.tech/badge/pyicloud
|
||||||
|
:alt: Downloads
|
||||||
|
:target: https://pypi.org/project/pyicloud
|
||||||
|
|
||||||
|
.. image:: https://requires.io/github/Quentame/pyicloud/requirements.svg?branch=master
|
||||||
|
:alt: Requirements Status
|
||||||
|
:target: https://requires.io/github/Quentame/pyicloud/requirements/?branch=master
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
|
||||||
|
:alt: Formated with Black
|
||||||
|
:target: https://github.com/psf/black
|
||||||
|
|
||||||
.. image:: https://badges.gitter.im/Join%20Chat.svg
|
.. image:: https://badges.gitter.im/Join%20Chat.svg
|
||||||
:alt: Join the chat at https://gitter.im/picklepete/pyicloud
|
:alt: Join the chat at https://gitter.im/picklepete/pyicloud
|
||||||
:target: https://gitter.im/picklepete/pyicloud?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
|
:target: https://gitter.im/picklepete/pyicloud?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
|
||||||
|
|
||||||
|
|
||||||
PyiCloud is a module which allows pythonistas to interact with iCloud webservices. It's powered by the fantastic `requests <https://github.com/kennethreitz/requests>`_ HTTP library.
|
PyiCloud is a module which allows pythonistas to interact with iCloud webservices. It's powered by the fantastic `requests <https://github.com/kennethreitz/requests>`_ HTTP library.
|
||||||
|
|
||||||
At its core, PyiCloud connects to iCloud using your username and password, then performs calendar and iPhone queries against their API.
|
At its core, PyiCloud connects to iCloud using your username and password, then performs calendar and iPhone queries against their API.
|
||||||
|
|
||||||
==============
|
|
||||||
Authentication
|
Authentication
|
||||||
==============
|
==============
|
||||||
|
|
||||||
Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class:
|
Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class:
|
||||||
|
|
||||||
>>> from pyicloud import PyiCloudService
|
.. code-block:: python
|
||||||
>>> api = PyiCloudService('jappleseed@apple.com', 'password')
|
|
||||||
|
from pyicloud import PyiCloudService
|
||||||
|
api = PyiCloudService('jappleseed@apple.com', 'password')
|
||||||
|
|
||||||
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
|
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
|
||||||
|
|
||||||
You can also store your password in the system keyring using the command-line tool:
|
You can also store your password in the system keyring using the command-line tool:
|
||||||
|
|
||||||
>>> icloud --username=jappleseed@apple.com
|
.. code-block:: console
|
||||||
ICloud Password for jappleseed@apple.com:
|
|
||||||
Save password in keyring? (y/N)
|
$ icloud --username=jappleseed@apple.com
|
||||||
|
ICloud Password for jappleseed@apple.com:
|
||||||
|
Save password in keyring? (y/N)
|
||||||
|
|
||||||
If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for.
|
If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for.
|
||||||
|
|
||||||
>>> api = PyiCloudService('jappleseed@apple.com')
|
.. code-block:: python
|
||||||
|
|
||||||
|
api = PyiCloudService('jappleseed@apple.com')
|
||||||
|
|
||||||
If you would like to delete a password stored in your system keyring, you can clear a stored password using the ``--delete-from-keyring`` command-line option:
|
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:
|
||||||
|
|
||||||
>>> icloud --username=jappleseed@apple.com --delete-from-keyring
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ icloud --username=jappleseed@apple.com --delete-from-keyring
|
||||||
|
|
||||||
**Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months.
|
**Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months.
|
||||||
|
|
||||||
************************************************
|
|
||||||
Two-step and two-factor authentication (2SA/2FA)
|
Two-step and two-factor authentication (2SA/2FA)
|
||||||
************************************************
|
************************************************
|
||||||
|
|
||||||
If you have enabled `two-step authentication (2SA) <https://support.apple.com/en-us/HT204152>`_ for the account you will have to do some extra work:
|
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:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
if api.requires_2sa:
|
if api.requires_2fa:
|
||||||
import click
|
print("Two-factor authentication required.")
|
||||||
print "Two-step authentication required. Your trusted devices are:"
|
code = input("Enter the code you received of one of your approved devices: ")
|
||||||
|
result = api.validate_2fa_code(code)
|
||||||
|
print("Code validation result: %s" % result)
|
||||||
|
|
||||||
devices = api.trusted_devices
|
if not result:
|
||||||
for i, device in enumerate(devices):
|
print("Failed to verify security code")
|
||||||
print " %s: %s" % (i, device.get('deviceName',
|
sys.exit(1)
|
||||||
"SMS to %s" % device.get('phoneNumber')))
|
|
||||||
|
|
||||||
device = click.prompt('Which device would you like to use?', default=0)
|
if not api.is_trusted_session:
|
||||||
device = devices[device]
|
print("Session is not trusted. Requesting trust...")
|
||||||
if not api.send_verification_code(device):
|
result = api.trust_session()
|
||||||
print "Failed to send verification code"
|
print("Session trust result %s" % result)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
code = click.prompt('Please enter validation code')
|
if not result:
|
||||||
if not api.validate_verification_code(device, code):
|
print("Failed to request trust. You will likely be prompted for the code again in the coming weeks")
|
||||||
print "Failed to verify verification code"
|
elif api.requires_2sa:
|
||||||
sys.exit(1)
|
import click
|
||||||
|
print("Two-step authentication required. Your trusted devices are:")
|
||||||
|
|
||||||
This approach also works if the account is set up for `two-factor authentication (2FA) <https://support.apple.com/en-us/HT204915>`_, but the authentication will time out after a few hours. Full support for two-factor authentication (2FA) is not implemented in PyiCloud yet. See issue `#102 <https://github.com/picklepete/pyicloud/issues/102>`_.
|
devices = api.trusted_devices
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
print(
|
||||||
|
" %s: %s" % (i, device.get('deviceName',
|
||||||
|
"SMS to %s" % device.get('phoneNumber')))
|
||||||
|
)
|
||||||
|
|
||||||
|
device = click.prompt('Which device would you like to use?', default=0)
|
||||||
|
device = devices[device]
|
||||||
|
if not api.send_verification_code(device):
|
||||||
|
print("Failed to send verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
code = click.prompt('Please enter validation code')
|
||||||
|
if not api.validate_verification_code(device, code):
|
||||||
|
print("Failed to verify verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
=======
|
|
||||||
Devices
|
Devices
|
||||||
=======
|
=======
|
||||||
|
|
||||||
You can list which devices associated with your account by using the ``devices`` property:
|
You can list which devices associated with your account by using the ``devices`` property:
|
||||||
|
|
||||||
>>> api.devices
|
.. code-block:: pycon
|
||||||
{
|
|
||||||
u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
|
>>> api.devices
|
||||||
u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
|
{
|
||||||
}
|
'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
|
||||||
|
'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
|
||||||
|
}
|
||||||
|
|
||||||
and you can access individual devices by either their index, or their ID:
|
and you can access individual devices by either their index, or their ID:
|
||||||
|
|
||||||
>>> api.devices[0]
|
.. code-block:: pycon
|
||||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
|
||||||
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
|
>>> api.devices[0]
|
||||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||||
|
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
|
||||||
|
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
>>> api.iphone
|
.. code-block:: pycon
|
||||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
|
||||||
|
>>> api.iphone
|
||||||
|
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||||
|
|
||||||
Note: the first device associated with your account may not necessarily be your iPhone.
|
Note: the first device associated with your account may not necessarily be your iPhone.
|
||||||
|
|
||||||
==============
|
|
||||||
Find My iPhone
|
Find My iPhone
|
||||||
==============
|
==============
|
||||||
|
|
||||||
Once you have successfully authenticated, you can start querying your data!
|
Once you have successfully authenticated, you can start querying your data!
|
||||||
|
|
||||||
********
|
|
||||||
Location
|
Location
|
||||||
********
|
********
|
||||||
|
|
||||||
Returns the device's last known location. The Find My iPhone app must have been installed and initialized.
|
Returns the device's last known location. The Find My iPhone app must have been installed and initialized.
|
||||||
|
|
||||||
>>> api.iphone.location()
|
.. code-block:: pycon
|
||||||
{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}
|
|
||||||
|
>>> api.iphone.location()
|
||||||
|
{'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0}
|
||||||
|
|
||||||
******
|
|
||||||
Status
|
Status
|
||||||
******
|
******
|
||||||
|
|
||||||
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
|
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
|
||||||
|
|
||||||
>>> api.iphone.status()
|
.. code-block:: pycon
|
||||||
{'deviceDisplayName': u'iPhone 5', 'deviceStatus': u'200', 'batteryLevel': 0.6166913, 'name': u"Peter's iPhone"}
|
|
||||||
|
>>> api.iphone.status()
|
||||||
|
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
|
||||||
|
|
||||||
If you wish to request further properties, you may do so by passing in a list of property names.
|
If you wish to request further properties, you may do so by passing in a list of property names.
|
||||||
|
|
||||||
**********
|
|
||||||
Play Sound
|
Play Sound
|
||||||
**********
|
**********
|
||||||
|
|
||||||
Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg.
|
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.
|
||||||
|
|
||||||
>>> api.iphone.play_sound()
|
.. code-block:: python
|
||||||
|
|
||||||
|
api.iphone.play_sound()
|
||||||
|
|
||||||
A few moments later, the device will play a ringtone, display the default notification ("Find My iPhone Alert") and a confirmation email will be sent to you.
|
A few moments later, the device will play a ringtone, display the default notification ("Find My iPhone Alert") and a confirmation email will be sent to you.
|
||||||
|
|
||||||
*********
|
|
||||||
Lost Mode
|
Lost Mode
|
||||||
*********
|
*********
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
>>> phone_number = '555-373-383'
|
.. code-block:: python
|
||||||
>>> message = 'Thief! Return my phone immediately.'
|
|
||||||
>>> api.iphone.lost_device(phone_number, message)
|
phone_number = '555-373-383'
|
||||||
|
message = 'Thief! Return my phone immediately.'
|
||||||
|
api.iphone.lost_device(phone_number, message)
|
||||||
|
|
||||||
|
|
||||||
========
|
|
||||||
Calendar
|
Calendar
|
||||||
========
|
========
|
||||||
|
|
||||||
The calendar webservice currently only supports fetching events.
|
The calendar webservice currently only supports fetching events.
|
||||||
|
|
||||||
******
|
|
||||||
Events
|
Events
|
||||||
******
|
******
|
||||||
|
|
||||||
Returns this month's events:
|
Returns this month's events:
|
||||||
|
|
||||||
>>> api.calendar.events()
|
.. code-block:: python
|
||||||
|
|
||||||
|
api.calendar.events()
|
||||||
|
|
||||||
Or, between a specific date range:
|
Or, between a specific date range:
|
||||||
|
|
||||||
>>> from_dt = datetime(2012, 1, 1)
|
.. code-block:: python
|
||||||
>>> to_dt = datetime(2012, 1, 31)
|
|
||||||
>>> api.calendar.events(from_dt, to_dt)
|
from_dt = datetime(2012, 1, 1)
|
||||||
|
to_dt = datetime(2012, 1, 31)
|
||||||
|
api.calendar.events(from_dt, to_dt)
|
||||||
|
|
||||||
Alternatively, you may fetch a single event's details, like so:
|
Alternatively, you may fetch a single event's details, like so:
|
||||||
|
|
||||||
>>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
|
.. code-block:: python
|
||||||
|
|
||||||
|
api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
|
||||||
|
|
||||||
|
|
||||||
========
|
|
||||||
Contacts
|
Contacts
|
||||||
========
|
========
|
||||||
|
|
||||||
You can access your iCloud contacts/address book through the ``contacts`` property:
|
You can access your iCloud contacts/address book through the ``contacts`` property:
|
||||||
|
|
||||||
>>> for c in api.contacts.all():
|
.. code-block:: pycon
|
||||||
>>> print c.get('firstName'), c.get('phones')
|
|
||||||
John [{u'field': u'+1 555-55-5555-5', u'label': u'MOBILE'}]
|
>>> for c in api.contacts.all():
|
||||||
|
>>> print(c.get('firstName'), c.get('phones'))
|
||||||
|
John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}]
|
||||||
|
|
||||||
Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud.
|
Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud.
|
||||||
|
|
||||||
=======================
|
|
||||||
File Storage (Ubiquity)
|
File Storage (Ubiquity)
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method:
|
You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method:
|
||||||
|
|
||||||
>>> api.files.dir()
|
.. code-block:: pycon
|
||||||
[u'.do-not-delete',
|
|
||||||
u'.localized',
|
>>> api.files.dir()
|
||||||
u'com~apple~Notes',
|
['.do-not-delete',
|
||||||
u'com~apple~Preview',
|
'.localized',
|
||||||
u'com~apple~mail',
|
'com~apple~Notes',
|
||||||
u'com~apple~shoebox',
|
'com~apple~Preview',
|
||||||
u'com~apple~system~spotlight'
|
'com~apple~mail',
|
||||||
]
|
'com~apple~shoebox',
|
||||||
|
'com~apple~system~spotlight'
|
||||||
|
]
|
||||||
|
|
||||||
You can access children and their children's children using the filename as an index:
|
You can access children and their children's children using the filename as an index:
|
||||||
|
|
||||||
>>> api.files['com~apple~Notes']
|
.. code-block:: pycon
|
||||||
<Folder: u'com~apple~Notes'>
|
|
||||||
>>> api.files['com~apple~Notes'].type
|
>>> api.files['com~apple~Notes']
|
||||||
u'folder'
|
<Folder: 'com~apple~Notes'>
|
||||||
>>> api.files['com~apple~Notes'].dir()
|
>>> api.files['com~apple~Notes'].type
|
||||||
[u'Documents']
|
'folder'
|
||||||
>>> api.files['com~apple~Notes']['Documents'].dir()
|
>>> api.files['com~apple~Notes'].dir()
|
||||||
[u'Some Document']
|
['Documents']
|
||||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
|
>>> api.files['com~apple~Notes']['Documents'].dir()
|
||||||
u'Some Document'
|
['Some Document']
|
||||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
|
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
|
||||||
datetime.datetime(2012, 9, 13, 2, 26, 17)
|
'Some Document'
|
||||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
|
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
|
||||||
1308134
|
datetime.datetime(2012, 9, 13, 2, 26, 17)
|
||||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
|
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
|
||||||
u'file'
|
1308134
|
||||||
|
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
|
||||||
|
'file'
|
||||||
|
|
||||||
And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``.
|
And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``.
|
||||||
|
|
||||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
|
.. code-block:: pycon
|
||||||
'Hello, these are the file contents'
|
|
||||||
|
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
|
||||||
|
'Hello, these are the file contents'
|
||||||
|
|
||||||
Note: the object returned from the above ``open`` method is a `response object <http://www.python-requests.org/en/latest/api/#classes>`_ and the ``open`` method can accept any parameters you might normally use in a request using `requests <https://github.com/kennethreitz/requests>`_.
|
Note: the object returned from the above ``open`` method is a `response object <http://www.python-requests.org/en/latest/api/#classes>`_ and the ``open`` method can accept any parameters you might normally use in a request using `requests <https://github.com/kennethreitz/requests>`_.
|
||||||
|
|
||||||
For example, if you know that the file you're opening has JSON content:
|
For example, if you know that the file you're opening has JSON content:
|
||||||
|
|
||||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
|
.. code-block:: pycon
|
||||||
{'How much we love you': 'lots'}
|
|
||||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
|
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
|
||||||
'lots'
|
{'How much we love you': 'lots'}
|
||||||
|
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
|
||||||
|
'lots'
|
||||||
|
|
||||||
Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object:
|
Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object:
|
||||||
|
|
||||||
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
|
.. code-block:: pycon
|
||||||
>>> with open('downloaded_file.zip', 'wb') as opened_file:
|
|
||||||
opened_file.write(download.raw.read())
|
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
|
||||||
|
>>> with open('downloaded_file.zip', 'wb') as opened_file:
|
||||||
|
opened_file.write(download.raw.read())
|
||||||
|
|
||||||
|
File Storage (iCloud Drive)
|
||||||
|
===========================
|
||||||
|
|
||||||
|
You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at ```api.drive```:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> api.drive.dir()
|
||||||
|
['Holiday Photos', 'Work Files']
|
||||||
|
>>> api.drive['Holiday Photos']['2013']['Sicily'].dir()
|
||||||
|
['DSC08116.JPG', 'DSC08117.JPG']
|
||||||
|
|
||||||
|
>>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG']
|
||||||
|
>>> drive_file.name
|
||||||
|
'DSC08116.JPG'
|
||||||
|
>>> drive_file.date_modified
|
||||||
|
datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC
|
||||||
|
>>> drive_file.size
|
||||||
|
2021698
|
||||||
|
>>> drive_file.type
|
||||||
|
'file'
|
||||||
|
|
||||||
|
The ``open`` method will return a response object from which you can read the file's contents:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from shutil import copyfileobj
|
||||||
|
with drive_file.open(stream=True) as response:
|
||||||
|
with open(drive_file.name, 'wb') as file_out:
|
||||||
|
copyfileobj(response.raw, file_out)
|
||||||
|
|
||||||
|
To interact with files and directions the ``mkdir``, ``rename`` and ``delete`` functions are available
|
||||||
|
for a file or folder:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
api.drive['Holiday Photos'].mkdir('2020')
|
||||||
|
api.drive['Holiday Photos']['2020'].rename('2020_copy')
|
||||||
|
api.drive['Holiday Photos']['2020_copy'].delete()
|
||||||
|
|
||||||
|
The ``upload`` method can be used to send a file-like object to the iCloud Drive:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
with open('Vacation.jpeg', 'rb') as file_in:
|
||||||
|
api.drive['Holiday Photos'].upload(file_in)
|
||||||
|
|
||||||
|
It is strongly suggested to open file handles as binary rather than text to prevent decoding errors
|
||||||
|
further down the line.
|
||||||
|
|
||||||
=======================
|
|
||||||
Photo Library
|
Photo Library
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
You can access the iCloud Photo Library through the ``photos`` property.
|
You can access the iCloud Photo Library through the ``photos`` property.
|
||||||
|
|
||||||
>>> api.photos.all
|
.. code-block:: pycon
|
||||||
<PhotoAlbum: 'All Photos'>
|
|
||||||
|
>>> api.photos.all
|
||||||
|
<PhotoAlbum: 'All Photos'>
|
||||||
|
|
||||||
Individual albums are available through the ``albums`` property:
|
Individual albums are available through the ``albums`` property:
|
||||||
|
|
||||||
>>> api.photos.albums['Screenshots']
|
.. code-block:: pycon
|
||||||
<PhotoAlbum: 'Screenshots'>
|
|
||||||
|
>>> api.photos.albums['Screenshots']
|
||||||
|
<PhotoAlbum: 'Screenshots'>
|
||||||
|
|
||||||
Which you can iterate to access the photo assets. The 'All Photos' album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) :
|
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) :
|
||||||
|
|
||||||
>>> for photo in api.photos.albums['Screenshots']:
|
.. code-block:: pycon
|
||||||
print photo, photo.filename
|
|
||||||
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
|
>>> for photo in api.photos.albums['Screenshots']:
|
||||||
|
print(photo, photo.filename)
|
||||||
|
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
>>> photo = next(iter(api.photos.albums['Screenshots']), None)
|
.. code-block:: python
|
||||||
>>> download = photo.download()
|
|
||||||
>>> with open(photo.filename, 'wb') as opened_file:
|
photo = next(iter(api.photos.albums['Screenshots']), None)
|
||||||
|
download = photo.download()
|
||||||
|
with open(photo.filename, 'wb') as opened_file:
|
||||||
opened_file.write(download.raw.read())
|
opened_file.write(download.raw.read())
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Information about each version can be accessed through the ``versions`` property:
|
Information about each version can be accessed through the ``versions`` property:
|
||||||
|
|
||||||
>>> photo.versions.keys()
|
.. code-block:: pycon
|
||||||
[u'medium', u'original', u'thumb']
|
|
||||||
|
>>> photo.versions.keys()
|
||||||
|
['medium', 'original', 'thumb']
|
||||||
|
|
||||||
To download a specific version of the photo asset, pass the version to ``download()``:
|
To download a specific version of the photo asset, pass the version to ``download()``:
|
||||||
|
|
||||||
>>> download = photo.download('thumb')
|
.. code-block:: python
|
||||||
>>> with open(photo.versions['thumb'].filename, 'wb') as thumb_file:
|
|
||||||
|
download = photo.download('thumb')
|
||||||
|
with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
|
||||||
thumb_file.write(download.raw.read())
|
thumb_file.write(download.raw.read())
|
||||||
|
|
||||||
|
|
||||||
|
Code samples
|
||||||
|
============
|
||||||
|
|
||||||
|
If you wanna see some code samples see the `code samples file </CODE_SAMPLES.md>`_.
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
"""The pyiCloud library."""
|
||||||
import logging
|
import logging
|
||||||
from pyicloud.base import PyiCloudService
|
from pyicloud.base import PyiCloudService
|
||||||
|
|
||||||
|
|
607
pyicloud/base.py
607
pyicloud/base.py
|
@ -1,20 +1,20 @@
|
||||||
import six
|
"""Library base file."""
|
||||||
import uuid
|
from uuid import uuid1
|
||||||
import hashlib
|
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import requests
|
from requests import Session
|
||||||
import sys
|
from tempfile import gettempdir
|
||||||
import tempfile
|
from os import path, mkdir
|
||||||
import os
|
|
||||||
from re import match
|
from re import match
|
||||||
|
import http.cookiejar as cookielib
|
||||||
|
import getpass
|
||||||
|
|
||||||
from pyicloud.exceptions import (
|
from pyicloud.exceptions import (
|
||||||
PyiCloudFailedLoginException,
|
PyiCloudFailedLoginException,
|
||||||
PyiCloudAPIResponseException,
|
PyiCloudAPIResponseException,
|
||||||
PyiCloud2SARequiredException,
|
PyiCloud2SARequiredException,
|
||||||
PyiCloudServiceNotActivatedException
|
PyiCloudServiceNotActivatedException,
|
||||||
)
|
)
|
||||||
from pyicloud.services import (
|
from pyicloud.services import (
|
||||||
FindMyiPhoneServiceManager,
|
FindMyiPhoneServiceManager,
|
||||||
|
@ -23,116 +23,173 @@ from pyicloud.services import (
|
||||||
ContactsService,
|
ContactsService,
|
||||||
RemindersService,
|
RemindersService,
|
||||||
PhotosService,
|
PhotosService,
|
||||||
AccountService
|
AccountService,
|
||||||
|
DriveService,
|
||||||
)
|
)
|
||||||
from pyicloud.utils import get_password_from_keyring
|
from pyicloud.utils import get_password_from_keyring
|
||||||
|
|
||||||
if six.PY3:
|
|
||||||
import http.cookiejar as cookielib
|
|
||||||
else:
|
|
||||||
import cookielib
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
HEADER_DATA = {
|
||||||
|
"X-Apple-ID-Account-Country": "account_country",
|
||||||
|
"X-Apple-ID-Session-Id": "session_id",
|
||||||
|
"X-Apple-Session-Token": "session_token",
|
||||||
|
"X-Apple-TwoSV-Trust-Token": "trust_token",
|
||||||
|
"scnt": "scnt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudPasswordFilter(logging.Filter):
|
class PyiCloudPasswordFilter(logging.Filter):
|
||||||
|
"""Password log hider."""
|
||||||
|
|
||||||
def __init__(self, password):
|
def __init__(self, password):
|
||||||
self.password = password
|
super().__init__(password)
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
message = record.getMessage()
|
message = record.getMessage()
|
||||||
if self.password in message:
|
if self.name in message:
|
||||||
record.msg = message.replace(self.password, "*" * 8)
|
record.msg = message.replace(self.name, "*" * 8)
|
||||||
record.args = []
|
record.args = []
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudSession(requests.Session):
|
class PyiCloudSession(Session):
|
||||||
|
"""iCloud session."""
|
||||||
|
|
||||||
def __init__(self, service):
|
def __init__(self, service):
|
||||||
self.service = service
|
self.service = service
|
||||||
super(PyiCloudSession, self).__init__()
|
super().__init__()
|
||||||
|
|
||||||
def request(self, *args, **kwargs):
|
def request(self, method, url, **kwargs): # pylint: disable=arguments-differ
|
||||||
|
|
||||||
# Charge logging to the right service endpoint
|
# Charge logging to the right service endpoint
|
||||||
callee = inspect.stack()[2]
|
callee = inspect.stack()[2]
|
||||||
module = inspect.getmodule(callee[0])
|
module = inspect.getmodule(callee[0])
|
||||||
logger = logging.getLogger(module.__name__).getChild('http')
|
request_logger = logging.getLogger(module.__name__).getChild("http")
|
||||||
if self.service._password_filter not in logger.filters:
|
if self.service.password_filter not in request_logger.filters:
|
||||||
logger.addFilter(self.service._password_filter)
|
request_logger.addFilter(self.service.password_filter)
|
||||||
|
|
||||||
logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', ''))
|
request_logger.debug("%s %s %s", method, url, kwargs.get("data", ""))
|
||||||
|
|
||||||
kwargs.pop('retried', None)
|
has_retried = kwargs.get("retried")
|
||||||
response = super(PyiCloudSession, self).request(*args, **kwargs)
|
kwargs.pop("retried", None)
|
||||||
|
response = super().request(method, url, **kwargs)
|
||||||
|
|
||||||
content_type = response.headers.get('Content-Type', '').split(';')[0]
|
content_type = response.headers.get("Content-Type", "").split(";")[0]
|
||||||
json_mimetypes = ['application/json', 'text/json']
|
json_mimetypes = ["application/json", "text/json"]
|
||||||
|
|
||||||
if not response.ok and content_type not in json_mimetypes:
|
for header, value in HEADER_DATA.items():
|
||||||
if kwargs.get('retried') is None and response.status_code == 450:
|
if response.headers.get(header):
|
||||||
api_error = PyiCloudAPIResponseException(
|
session_arg = value
|
||||||
response.reason,
|
self.service.session_data.update(
|
||||||
response.status_code,
|
{session_arg: response.headers.get(header)}
|
||||||
retry=True
|
|
||||||
)
|
)
|
||||||
logger.warn(api_error)
|
|
||||||
kwargs['retried'] = True
|
# Save session_data to file
|
||||||
return self.request(*args, **kwargs)
|
with open(self.service.session_path, "w", encoding="utf-8") as outfile:
|
||||||
|
json.dump(self.service.session_data, outfile)
|
||||||
|
LOGGER.debug("Saved session data to file")
|
||||||
|
|
||||||
|
# Save cookies to file
|
||||||
|
self.cookies.save(ignore_discard=True, ignore_expires=True)
|
||||||
|
LOGGER.debug("Cookies saved to %s", self.service.cookiejar_path)
|
||||||
|
|
||||||
|
if not response.ok and (
|
||||||
|
content_type not in json_mimetypes
|
||||||
|
or response.status_code in [421, 450, 500]
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
fmip_url = self.service._get_webservice_url("findme")
|
||||||
|
if (
|
||||||
|
has_retried is None
|
||||||
|
and response.status_code in [421, 450, 500]
|
||||||
|
and fmip_url in url
|
||||||
|
):
|
||||||
|
# Handle re-authentication for Find My iPhone
|
||||||
|
LOGGER.debug("Re-authenticating Find My iPhone service")
|
||||||
|
try:
|
||||||
|
# If 450, authentication requires a full sign in to the account
|
||||||
|
service = None if response.status_code == 450 else "find"
|
||||||
|
self.service.authenticate(True, service)
|
||||||
|
|
||||||
|
except PyiCloudAPIResponseException:
|
||||||
|
LOGGER.debug("Re-authentication failed")
|
||||||
|
kwargs["retried"] = True
|
||||||
|
return self.request(method, url, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if has_retried is None and response.status_code in [421, 450, 500]:
|
||||||
|
api_error = PyiCloudAPIResponseException(
|
||||||
|
response.reason, response.status_code, retry=True
|
||||||
|
)
|
||||||
|
request_logger.debug(api_error)
|
||||||
|
kwargs["retried"] = True
|
||||||
|
return self.request(method, url, **kwargs)
|
||||||
|
|
||||||
self._raise_error(response.status_code, response.reason)
|
self._raise_error(response.status_code, response.reason)
|
||||||
|
|
||||||
if content_type not in json_mimetypes:
|
if content_type not in json_mimetypes:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
json = response.json()
|
data = response.json()
|
||||||
except:
|
except: # pylint: disable=bare-except
|
||||||
logger.warning('Failed to parse response with JSON mimetype')
|
request_logger.warning("Failed to parse response with JSON mimetype")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
logger.debug(json)
|
request_logger.debug(data)
|
||||||
|
|
||||||
reason = json.get('errorMessage')
|
if isinstance(data, dict):
|
||||||
reason = reason or json.get('reason')
|
reason = data.get("errorMessage")
|
||||||
reason = reason or json.get('errorReason')
|
reason = reason or data.get("reason")
|
||||||
if not reason and isinstance(json.get('error'), six.string_types):
|
reason = reason or data.get("errorReason")
|
||||||
reason = json.get('error')
|
if not reason and isinstance(data.get("error"), str):
|
||||||
if not reason and json.get('error'):
|
reason = data.get("error")
|
||||||
reason = "Unknown reason"
|
if not reason and data.get("error"):
|
||||||
|
reason = "Unknown reason"
|
||||||
|
|
||||||
code = json.get('errorCode')
|
code = data.get("errorCode")
|
||||||
if not code and json.get('serverErrorCode'):
|
if not code and data.get("serverErrorCode"):
|
||||||
code = json.get('serverErrorCode')
|
code = data.get("serverErrorCode")
|
||||||
|
|
||||||
if reason:
|
if reason:
|
||||||
self._raise_error(code, reason)
|
self._raise_error(code, reason)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _raise_error(self, code, reason):
|
def _raise_error(self, code, reason):
|
||||||
if self.service.requires_2sa and \
|
if (
|
||||||
reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie':
|
self.service.requires_2sa
|
||||||
raise PyiCloud2SARequiredException(self.service.user['apple_id'])
|
and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie"
|
||||||
if code == 'ZONE_NOT_FOUND' or code == 'AUTHENTICATION_FAILED':
|
):
|
||||||
reason = 'Please log into https://icloud.com/ to manually ' \
|
raise PyiCloud2SARequiredException(self.service.user["apple_id"])
|
||||||
'finish setting up your iCloud service'
|
if code in ("ZONE_NOT_FOUND", "AUTHENTICATION_FAILED"):
|
||||||
|
reason = (
|
||||||
|
"Please log into https://icloud.com/ to manually "
|
||||||
|
"finish setting up your iCloud service"
|
||||||
|
)
|
||||||
api_error = PyiCloudServiceNotActivatedException(reason, code)
|
api_error = PyiCloudServiceNotActivatedException(reason, code)
|
||||||
logger.error(api_error)
|
LOGGER.error(api_error)
|
||||||
|
|
||||||
raise(api_error)
|
raise (api_error)
|
||||||
if code == 'ACCESS_DENIED':
|
if code == "ACCESS_DENIED":
|
||||||
reason = reason + '. Please wait a few minutes then try ' \
|
reason = (
|
||||||
'again. The remote servers might be trying to ' \
|
reason + ". Please wait a few minutes then try again."
|
||||||
'throttle requests.'
|
"The remote servers might be trying to throttle requests."
|
||||||
|
)
|
||||||
|
if code in [421, 450, 500]:
|
||||||
|
reason = "Authentication required for Account."
|
||||||
|
|
||||||
api_error = PyiCloudAPIResponseException(reason, code)
|
api_error = PyiCloudAPIResponseException(reason, code)
|
||||||
logger.error(api_error)
|
LOGGER.error(api_error)
|
||||||
raise api_error
|
raise api_error
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudService(object):
|
class PyiCloudService:
|
||||||
"""
|
"""
|
||||||
A base authentication class for the iCloud service. Handles the
|
A base authentication class for the iCloud service. Handles the
|
||||||
authentication required to access iCloud services.
|
authentication required to access iCloud services.
|
||||||
|
@ -143,149 +200,272 @@ class PyiCloudService(object):
|
||||||
pyicloud.iphone.location()
|
pyicloud.iphone.location()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth"
|
||||||
|
HOME_ENDPOINT = "https://www.icloud.com"
|
||||||
|
SETUP_ENDPOINT = "https://setup.icloud.com/setup/ws/1"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, apple_id, password=None, cookie_directory=None, verify=True,
|
self,
|
||||||
client_id=None, with_family=True
|
apple_id,
|
||||||
|
password=None,
|
||||||
|
cookie_directory=None,
|
||||||
|
verify=True,
|
||||||
|
client_id=None,
|
||||||
|
with_family=True,
|
||||||
):
|
):
|
||||||
if password is None:
|
if password is None:
|
||||||
password = get_password_from_keyring(apple_id)
|
password = get_password_from_keyring(apple_id)
|
||||||
|
|
||||||
|
self.user = {"accountName": apple_id, "password": password}
|
||||||
self.data = {}
|
self.data = {}
|
||||||
self.client_id = client_id or str(uuid.uuid1()).upper()
|
self.params = {}
|
||||||
|
self.client_id = client_id or ("auth-%s" % str(uuid1()).lower())
|
||||||
self.with_family = with_family
|
self.with_family = with_family
|
||||||
self.user = {'apple_id': apple_id, 'password': password}
|
|
||||||
|
|
||||||
self._password_filter = PyiCloudPasswordFilter(password)
|
self.password_filter = PyiCloudPasswordFilter(password)
|
||||||
logger.addFilter(self._password_filter)
|
LOGGER.addFilter(self.password_filter)
|
||||||
|
|
||||||
self._home_endpoint = 'https://www.icloud.com'
|
|
||||||
self._setup_endpoint = 'https://setup.icloud.com/setup/ws/1'
|
|
||||||
|
|
||||||
self._base_login_url = '%s/login' % self._setup_endpoint
|
|
||||||
|
|
||||||
if cookie_directory:
|
if cookie_directory:
|
||||||
self._cookie_directory = os.path.expanduser(
|
self._cookie_directory = path.expanduser(path.normpath(cookie_directory))
|
||||||
os.path.normpath(cookie_directory)
|
if not path.exists(self._cookie_directory):
|
||||||
)
|
mkdir(self._cookie_directory, 0o700)
|
||||||
else:
|
else:
|
||||||
self._cookie_directory = os.path.join(
|
topdir = path.join(gettempdir(), "pyicloud")
|
||||||
tempfile.gettempdir(),
|
self._cookie_directory = path.join(topdir, getpass.getuser())
|
||||||
'pyicloud',
|
if not path.exists(topdir):
|
||||||
)
|
mkdir(topdir, 0o777)
|
||||||
|
if not path.exists(self._cookie_directory):
|
||||||
|
mkdir(self._cookie_directory, 0o700)
|
||||||
|
|
||||||
|
LOGGER.debug("Using session file %s", self.session_path)
|
||||||
|
|
||||||
|
self.session_data = {}
|
||||||
|
try:
|
||||||
|
with open(self.session_path, encoding="utf-8") as session_f:
|
||||||
|
self.session_data = json.load(session_f)
|
||||||
|
except: # pylint: disable=bare-except
|
||||||
|
LOGGER.info("Session file does not exist")
|
||||||
|
if self.session_data.get("client_id"):
|
||||||
|
self.client_id = self.session_data.get("client_id")
|
||||||
|
else:
|
||||||
|
self.session_data.update({"client_id": self.client_id})
|
||||||
|
|
||||||
self.session = PyiCloudSession(self)
|
self.session = PyiCloudSession(self)
|
||||||
self.session.verify = verify
|
self.session.verify = verify
|
||||||
self.session.headers.update({
|
self.session.headers.update(
|
||||||
'Origin': self._home_endpoint,
|
{"Origin": self.HOME_ENDPOINT, "Referer": "%s/" % self.HOME_ENDPOINT}
|
||||||
'Referer': '%s/' % self._home_endpoint,
|
)
|
||||||
'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)'
|
|
||||||
})
|
|
||||||
|
|
||||||
cookiejar_path = self._get_cookiejar_path()
|
cookiejar_path = self.cookiejar_path
|
||||||
self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path)
|
self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path)
|
||||||
if os.path.exists(cookiejar_path):
|
if path.exists(cookiejar_path):
|
||||||
try:
|
try:
|
||||||
self.session.cookies.load()
|
self.session.cookies.load(ignore_discard=True, ignore_expires=True)
|
||||||
logger.debug("Read cookies from %s", cookiejar_path)
|
LOGGER.debug("Read cookies from %s", cookiejar_path)
|
||||||
except:
|
except: # pylint: disable=bare-except
|
||||||
# Most likely a pickled cookiejar from earlier versions.
|
# Most likely a pickled cookiejar from earlier versions.
|
||||||
# The cookiejar will get replaced with a valid one after
|
# The cookiejar will get replaced with a valid one after
|
||||||
# successful authentication.
|
# successful authentication.
|
||||||
logger.warning("Failed to read cookiejar %s", cookiejar_path)
|
LOGGER.warning("Failed to read cookiejar %s", cookiejar_path)
|
||||||
|
|
||||||
self.params = {
|
|
||||||
'clientBuildNumber': '17DHotfix5',
|
|
||||||
'clientMasteringNumber': '17DHotfix5',
|
|
||||||
'ckjsBuildVersion': '17DProjectDev77',
|
|
||||||
'ckjsVersion': '2.0.5',
|
|
||||||
'clientId': self.client_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
|
|
||||||
def authenticate(self):
|
self._drive = None
|
||||||
|
self._files = None
|
||||||
|
self._photos = None
|
||||||
|
|
||||||
|
def authenticate(self, force_refresh=False, service=None):
|
||||||
"""
|
"""
|
||||||
Handles authentication, and persists the X-APPLE-WEB-KB cookie so that
|
Handles authentication, and persists cookies so that
|
||||||
subsequent logins will not cause additional e-mails from Apple.
|
subsequent logins will not cause additional e-mails from Apple.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logger.info("Authenticating as %s", self.user['apple_id'])
|
login_successful = False
|
||||||
|
if self.session_data.get("session_token") and not force_refresh:
|
||||||
|
LOGGER.debug("Checking session token validity")
|
||||||
|
try:
|
||||||
|
self.data = self._validate_token()
|
||||||
|
login_successful = True
|
||||||
|
except PyiCloudAPIResponseException:
|
||||||
|
LOGGER.debug("Invalid authentication token, will log in from scratch.")
|
||||||
|
|
||||||
data = dict(self.user)
|
if not login_successful and service is not None:
|
||||||
|
app = self.data["apps"][service]
|
||||||
|
if "canLaunchWithOneFactor" in app and app["canLaunchWithOneFactor"]:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Authenticating as %s for %s", self.user["accountName"], service
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._authenticate_with_credentials_service(service)
|
||||||
|
login_successful = True
|
||||||
|
except Exception:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Could not log into service. Attempting brand new login."
|
||||||
|
)
|
||||||
|
|
||||||
# We authenticate every time, so "remember me" is not needed
|
if not login_successful:
|
||||||
data.update({'extended_login': False})
|
LOGGER.debug("Authenticating as %s", self.user["accountName"])
|
||||||
|
|
||||||
|
data = dict(self.user)
|
||||||
|
|
||||||
|
data["rememberMe"] = True
|
||||||
|
data["trustTokens"] = []
|
||||||
|
if self.session_data.get("trust_token"):
|
||||||
|
data["trustTokens"] = [self.session_data.get("trust_token")]
|
||||||
|
|
||||||
|
headers = self._get_auth_headers()
|
||||||
|
|
||||||
|
if self.session_data.get("scnt"):
|
||||||
|
headers["scnt"] = self.session_data.get("scnt")
|
||||||
|
|
||||||
|
if self.session_data.get("session_id"):
|
||||||
|
headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.session.post(
|
||||||
|
"%s/signin" % self.AUTH_ENDPOINT,
|
||||||
|
params={"isRememberMeEnabled": "true"},
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
except PyiCloudAPIResponseException as error:
|
||||||
|
msg = "Invalid email/password combination."
|
||||||
|
raise PyiCloudFailedLoginException(msg, error) from error
|
||||||
|
|
||||||
|
self._authenticate_with_token()
|
||||||
|
|
||||||
|
self._webservices = self.data["webservices"]
|
||||||
|
|
||||||
|
LOGGER.debug("Authentication completed successfully")
|
||||||
|
|
||||||
|
def _authenticate_with_token(self):
|
||||||
|
"""Authenticate using session token."""
|
||||||
|
data = {
|
||||||
|
"accountCountryCode": self.session_data.get("account_country"),
|
||||||
|
"dsWebAuthToken": self.session_data.get("session_token"),
|
||||||
|
"extended_login": True,
|
||||||
|
"trustToken": self.session_data.get("trust_token", ""),
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = self.session.post(
|
req = self.session.post(
|
||||||
self._base_login_url,
|
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
|
||||||
params=self.params,
|
|
||||||
data=json.dumps(data)
|
|
||||||
)
|
)
|
||||||
|
self.data = req.json()
|
||||||
except PyiCloudAPIResponseException as error:
|
except PyiCloudAPIResponseException as error:
|
||||||
msg = 'Invalid email/password combination.'
|
msg = "Invalid authentication token."
|
||||||
raise PyiCloudFailedLoginException(msg, error)
|
raise PyiCloudFailedLoginException(msg, error) from error
|
||||||
|
|
||||||
resp = req.json()
|
def _authenticate_with_credentials_service(self, service):
|
||||||
self.params.update({'dsid': resp['dsInfo']['dsid']})
|
"""Authenticate to a specific service using credentials."""
|
||||||
|
data = {
|
||||||
|
"appName": service,
|
||||||
|
"apple_id": self.user["accountName"],
|
||||||
|
"password": self.user["password"],
|
||||||
|
}
|
||||||
|
|
||||||
if not os.path.exists(self._cookie_directory):
|
try:
|
||||||
os.mkdir(self._cookie_directory)
|
self.session.post(
|
||||||
self.session.cookies.save()
|
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
|
||||||
logger.debug("Cookies saved to %s", self._get_cookiejar_path())
|
)
|
||||||
|
|
||||||
self.data = resp
|
self.data = self._validate_token()
|
||||||
self._webservices = self.data['webservices']
|
except PyiCloudAPIResponseException as error:
|
||||||
|
msg = "Invalid email/password combination."
|
||||||
|
raise PyiCloudFailedLoginException(msg, error) from error
|
||||||
|
|
||||||
logger.info("Authentication completed successfully")
|
def _validate_token(self):
|
||||||
logger.debug(self.params)
|
"""Checks if the current access token is still valid."""
|
||||||
|
LOGGER.debug("Checking session token validity")
|
||||||
|
try:
|
||||||
|
req = self.session.post("%s/validate" % self.SETUP_ENDPOINT, data="null")
|
||||||
|
LOGGER.debug("Session token is still valid")
|
||||||
|
return req.json()
|
||||||
|
except PyiCloudAPIResponseException as err:
|
||||||
|
LOGGER.debug("Invalid authentication token")
|
||||||
|
raise err
|
||||||
|
|
||||||
def _get_cookiejar_path(self):
|
def _get_auth_headers(self, overrides=None):
|
||||||
# Get path for cookiejar file
|
headers = {
|
||||||
return os.path.join(
|
"Accept": "*/*",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Apple-OAuth-Client-Id": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d",
|
||||||
|
"X-Apple-OAuth-Client-Type": "firstPartyAuth",
|
||||||
|
"X-Apple-OAuth-Redirect-URI": "https://www.icloud.com",
|
||||||
|
"X-Apple-OAuth-Require-Grant-Code": "true",
|
||||||
|
"X-Apple-OAuth-Response-Mode": "web_message",
|
||||||
|
"X-Apple-OAuth-Response-Type": "code",
|
||||||
|
"X-Apple-OAuth-State": self.client_id,
|
||||||
|
"X-Apple-Widget-Key": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d",
|
||||||
|
}
|
||||||
|
if overrides:
|
||||||
|
headers.update(overrides)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookiejar_path(self):
|
||||||
|
"""Get path for cookiejar file."""
|
||||||
|
return path.join(
|
||||||
self._cookie_directory,
|
self._cookie_directory,
|
||||||
''.join([c for c in self.user.get('apple_id') if match(r'\w', c)])
|
"".join([c for c in self.user.get("accountName") if match(r"\w", c)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_path(self):
|
||||||
|
"""Get path for session data file."""
|
||||||
|
return path.join(
|
||||||
|
self._cookie_directory,
|
||||||
|
"".join([c for c in self.user.get("accountName") if match(r"\w", c)])
|
||||||
|
+ ".session",
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_2sa(self):
|
def requires_2sa(self):
|
||||||
"""Returns True if two-step authentication is required."""
|
"""Returns True if two-step authentication is required."""
|
||||||
return self.data.get('hsaChallengeRequired', False) \
|
return self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 and (
|
||||||
and self.data['dsInfo'].get('hsaVersion', 0) >= 1
|
self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session
|
||||||
# FIXME: Implement 2FA for hsaVersion == 2
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_2fa(self):
|
||||||
|
"""Returns True if two-factor authentication is required."""
|
||||||
|
return self.data["dsInfo"].get("hsaVersion", 0) == 2 and (
|
||||||
|
self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_trusted_session(self):
|
||||||
|
"""Returns True if the session is trusted."""
|
||||||
|
return self.data.get("hsaTrustedBrowser", False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def trusted_devices(self):
|
def trusted_devices(self):
|
||||||
"""Returns devices trusted for two-step authentication."""
|
"""Returns devices trusted for two-step authentication."""
|
||||||
request = self.session.get(
|
request = self.session.get(
|
||||||
'%s/listDevices' % self._setup_endpoint,
|
"%s/listDevices" % self.SETUP_ENDPOINT, params=self.params
|
||||||
params=self.params
|
|
||||||
)
|
)
|
||||||
return request.json().get('devices')
|
return request.json().get("devices")
|
||||||
|
|
||||||
def send_verification_code(self, device):
|
def send_verification_code(self, device):
|
||||||
"""Requests that a verification code is sent to the given device."""
|
"""Requests that a verification code is sent to the given device."""
|
||||||
data = json.dumps(device)
|
data = json.dumps(device)
|
||||||
request = self.session.post(
|
request = self.session.post(
|
||||||
'%s/sendVerificationCode' % self._setup_endpoint,
|
"%s/sendVerificationCode" % self.SETUP_ENDPOINT,
|
||||||
params=self.params,
|
params=self.params,
|
||||||
data=data
|
data=data,
|
||||||
)
|
)
|
||||||
return request.json().get('success', False)
|
return request.json().get("success", False)
|
||||||
|
|
||||||
def validate_verification_code(self, device, code):
|
def validate_verification_code(self, device, code):
|
||||||
"""Verifies a verification code received on a trusted device."""
|
"""Verifies a verification code received on a trusted device."""
|
||||||
device.update({
|
device.update({"verificationCode": code, "trustBrowser": True})
|
||||||
'verificationCode': code,
|
|
||||||
'trustBrowser': True
|
|
||||||
})
|
|
||||||
data = json.dumps(device)
|
data = json.dumps(device)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = self.session.post(
|
self.session.post(
|
||||||
'%s/validateVerificationCode' % self._setup_endpoint,
|
"%s/validateVerificationCode" % self.SETUP_ENDPOINT,
|
||||||
params=self.params,
|
params=self.params,
|
||||||
data=data
|
data=data,
|
||||||
)
|
)
|
||||||
except PyiCloudAPIResponseException as error:
|
except PyiCloudAPIResponseException as error:
|
||||||
if error.code == -21669:
|
if error.code == -21669:
|
||||||
|
@ -293,91 +473,136 @@ class PyiCloudService(object):
|
||||||
return False
|
return False
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Re-authenticate, which will both update the HSA data, and
|
self.trust_session()
|
||||||
# ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie.
|
|
||||||
self.authenticate()
|
|
||||||
|
|
||||||
return not self.requires_2sa
|
return not self.requires_2sa
|
||||||
|
|
||||||
|
def validate_2fa_code(self, code):
|
||||||
|
"""Verifies a verification code received via Apple's 2FA system (HSA2)."""
|
||||||
|
data = {"securityCode": {"code": code}}
|
||||||
|
|
||||||
|
headers = self._get_auth_headers({"Accept": "application/json"})
|
||||||
|
|
||||||
|
if self.session_data.get("scnt"):
|
||||||
|
headers["scnt"] = self.session_data.get("scnt")
|
||||||
|
|
||||||
|
if self.session_data.get("session_id"):
|
||||||
|
headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.session.post(
|
||||||
|
"%s/verify/trusteddevice/securitycode" % self.AUTH_ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
except PyiCloudAPIResponseException as error:
|
||||||
|
if error.code == -21669:
|
||||||
|
# Wrong verification code
|
||||||
|
LOGGER.error("Code verification failed.")
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
LOGGER.debug("Code verification successful.")
|
||||||
|
|
||||||
|
self.trust_session()
|
||||||
|
return not self.requires_2sa
|
||||||
|
|
||||||
|
def trust_session(self):
|
||||||
|
"""Request session trust to avoid user log in going forward."""
|
||||||
|
headers = self._get_auth_headers()
|
||||||
|
|
||||||
|
if self.session_data.get("scnt"):
|
||||||
|
headers["scnt"] = self.session_data.get("scnt")
|
||||||
|
|
||||||
|
if self.session_data.get("session_id"):
|
||||||
|
headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.session.get(
|
||||||
|
f"{self.AUTH_ENDPOINT}/2sv/trust",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self._authenticate_with_token()
|
||||||
|
return True
|
||||||
|
except PyiCloudAPIResponseException:
|
||||||
|
LOGGER.error("Session trust failed.")
|
||||||
|
return False
|
||||||
|
|
||||||
def _get_webservice_url(self, ws_key):
|
def _get_webservice_url(self, ws_key):
|
||||||
"""Get webservice URL, raise an exception if not exists."""
|
"""Get webservice URL, raise an exception if not exists."""
|
||||||
if self._webservices.get(ws_key) is None:
|
if self._webservices.get(ws_key) is None:
|
||||||
raise PyiCloudServiceNotActivatedException(
|
raise PyiCloudServiceNotActivatedException(
|
||||||
'Webservice not available',
|
"Webservice not available", ws_key
|
||||||
ws_key
|
|
||||||
)
|
)
|
||||||
return self._webservices[ws_key]['url']
|
return self._webservices[ws_key]["url"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def devices(self):
|
def devices(self):
|
||||||
"""Return all devices."""
|
"""Returns all devices."""
|
||||||
service_root = self._get_webservice_url('findme')
|
service_root = self._get_webservice_url("findme")
|
||||||
return FindMyiPhoneServiceManager(
|
return FindMyiPhoneServiceManager(
|
||||||
service_root,
|
service_root, self.session, self.params, self.with_family
|
||||||
self.session,
|
|
||||||
self.params,
|
|
||||||
self.with_family
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account(self):
|
|
||||||
service_root = self._get_webservice_url('account')
|
|
||||||
return AccountService(
|
|
||||||
service_root,
|
|
||||||
self.session,
|
|
||||||
self.params
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def iphone(self):
|
def iphone(self):
|
||||||
|
"""Returns the iPhone."""
|
||||||
return self.devices[0]
|
return self.devices[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account(self):
|
||||||
|
"""Gets the 'Account' service."""
|
||||||
|
service_root = self._get_webservice_url("account")
|
||||||
|
return AccountService(service_root, self.session, self.params)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def files(self):
|
def files(self):
|
||||||
if not hasattr(self, '_files'):
|
"""Gets the 'File' service."""
|
||||||
service_root = self._get_webservice_url('ubiquity')
|
if not self._files:
|
||||||
self._files = UbiquityService(
|
service_root = self._get_webservice_url("ubiquity")
|
||||||
service_root,
|
self._files = UbiquityService(service_root, self.session, self.params)
|
||||||
self.session,
|
|
||||||
self.params
|
|
||||||
)
|
|
||||||
return self._files
|
return self._files
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def photos(self):
|
def photos(self):
|
||||||
if not hasattr(self, '_photos'):
|
"""Gets the 'Photo' service."""
|
||||||
service_root = self._get_webservice_url('ckdatabasews')
|
if not self._photos:
|
||||||
self._photos = PhotosService(
|
service_root = self._get_webservice_url("ckdatabasews")
|
||||||
service_root,
|
self._photos = PhotosService(service_root, self.session, self.params)
|
||||||
self.session,
|
|
||||||
self.params
|
|
||||||
)
|
|
||||||
return self._photos
|
return self._photos
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def calendar(self):
|
def calendar(self):
|
||||||
service_root = self._get_webservice_url('calendar')
|
"""Gets the 'Calendar' service."""
|
||||||
|
service_root = self._get_webservice_url("calendar")
|
||||||
return CalendarService(service_root, self.session, self.params)
|
return CalendarService(service_root, self.session, self.params)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def contacts(self):
|
def contacts(self):
|
||||||
service_root = self._get_webservice_url('contacts')
|
"""Gets the 'Contacts' service."""
|
||||||
|
service_root = self._get_webservice_url("contacts")
|
||||||
return ContactsService(service_root, self.session, self.params)
|
return ContactsService(service_root, self.session, self.params)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reminders(self):
|
def reminders(self):
|
||||||
service_root = self._get_webservice_url('reminders')
|
"""Gets the 'Reminders' service."""
|
||||||
|
service_root = self._get_webservice_url("reminders")
|
||||||
return RemindersService(service_root, self.session, self.params)
|
return RemindersService(service_root, self.session, self.params)
|
||||||
|
|
||||||
def __unicode__(self):
|
@property
|
||||||
return 'iCloud API: %s' % self.user.get('apple_id')
|
def drive(self):
|
||||||
|
"""Gets the 'Drive' service."""
|
||||||
|
if not self._drive:
|
||||||
|
self._drive = DriveService(
|
||||||
|
service_root=self._get_webservice_url("drivews"),
|
||||||
|
document_root=self._get_webservice_url("docws"),
|
||||||
|
session=self.session,
|
||||||
|
params=self.params,
|
||||||
|
)
|
||||||
|
return self._drive
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
as_unicode = self.__unicode__()
|
return f"iCloud API: {self.user.get('apple_id')}"
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
return as_unicode
|
|
||||||
else:
|
|
||||||
return as_unicode.encode('utf-8', 'ignore')
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<%s>' % str(self)
|
return f"<{self}>"
|
||||||
|
|
|
@ -1,54 +1,46 @@
|
||||||
#! /usr/bin/env python
|
#! /usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
A Command Line Wrapper to allow easy use of pyicloud for
|
A Command Line Wrapper to allow easy use of pyicloud for
|
||||||
command line scripts, and related.
|
command line scripts, and related.
|
||||||
"""
|
"""
|
||||||
from __future__ import print_function
|
|
||||||
import argparse
|
import argparse
|
||||||
import pickle
|
import pickle
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from click import confirm
|
from click import confirm
|
||||||
|
|
||||||
import pyicloud
|
from pyicloud import PyiCloudService
|
||||||
|
from pyicloud.exceptions import PyiCloudFailedLoginException
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
DEVICE_ERROR = "Please use the --device switch to indicate which device to use."
|
||||||
DEVICE_ERROR = (
|
|
||||||
"Please use the --device switch to indicate which device to use."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_pickled_data(idevice, filename):
|
def create_pickled_data(idevice, filename):
|
||||||
"""This helper will output the idevice to a pickled file named
|
"""
|
||||||
|
This helper will output the idevice to a pickled file named
|
||||||
after the passed filename.
|
after the passed filename.
|
||||||
|
|
||||||
This allows the data to be used without resorting to screen / pipe
|
This allows the data to be used without resorting to screen / pipe
|
||||||
scrapping. """
|
scrapping.
|
||||||
data = {}
|
"""
|
||||||
for x in idevice.content:
|
with open(filename, "wb") as pickle_file:
|
||||||
data[x] = idevice.content[x]
|
pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
location = filename
|
|
||||||
pickle_file = open(location, 'wb')
|
|
||||||
pickle.dump(data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
|
||||||
pickle_file.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
"""Main commandline entrypoint"""
|
"""Main commandline entrypoint."""
|
||||||
if args is None:
|
if args is None:
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool")
|
||||||
description="Find My iPhone CommandLine Tool")
|
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--username",
|
"--username",
|
||||||
action="store",
|
action="store",
|
||||||
dest="username",
|
dest="username",
|
||||||
default="",
|
default="",
|
||||||
help="Apple ID to Use"
|
help="Apple ID to Use",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--password",
|
"--password",
|
||||||
|
@ -58,7 +50,7 @@ def main(args=None):
|
||||||
help=(
|
help=(
|
||||||
"Apple ID Password to Use; if unspecified, password will be "
|
"Apple ID Password to Use; if unspecified, password will be "
|
||||||
"fetched from the system keyring."
|
"fetched from the system keyring."
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-n",
|
"-n",
|
||||||
|
@ -66,7 +58,7 @@ def main(args=None):
|
||||||
action="store_false",
|
action="store_false",
|
||||||
dest="interactive",
|
dest="interactive",
|
||||||
default=True,
|
default=True,
|
||||||
help="Disable interactive prompts."
|
help="Disable interactive prompts.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--delete-from-keyring",
|
"--delete-from-keyring",
|
||||||
|
@ -97,7 +89,7 @@ def main(args=None):
|
||||||
help="Retrieve Location for the iDevice (non-exclusive).",
|
help="Retrieve Location for the iDevice (non-exclusive).",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restrict actions to a specific devices UID / DID
|
# Restrict actions to a specific devices UID / DID
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--device",
|
"--device",
|
||||||
action="store",
|
action="store",
|
||||||
|
@ -106,7 +98,7 @@ def main(args=None):
|
||||||
help="Only effect this device",
|
help="Only effect this device",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger Sound Alert
|
# Trigger Sound Alert
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--sound",
|
"--sound",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
@ -115,7 +107,7 @@ def main(args=None):
|
||||||
help="Play a sound on the device",
|
help="Play a sound on the device",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger Message w/Sound Alert
|
# Trigger Message w/Sound Alert
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--message",
|
"--message",
|
||||||
action="store",
|
action="store",
|
||||||
|
@ -124,7 +116,7 @@ def main(args=None):
|
||||||
help="Optional Text Message to display with a sound",
|
help="Optional Text Message to display with a sound",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger Message (without Sound) Alert
|
# Trigger Message (without Sound) Alert
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--silentmessage",
|
"--silentmessage",
|
||||||
action="store",
|
action="store",
|
||||||
|
@ -133,7 +125,7 @@ def main(args=None):
|
||||||
help="Optional Text Message to display with no sounds",
|
help="Optional Text Message to display with no sounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Lost Mode
|
# Lost Mode
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--lostmode",
|
"--lostmode",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
@ -163,7 +155,7 @@ def main(args=None):
|
||||||
help="Forcibly display this message when activating lost mode.",
|
help="Forcibly display this message when activating lost mode.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Output device data to an pickle file
|
# Output device data to an pickle file
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--outputfile",
|
"--outputfile",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
@ -185,55 +177,76 @@ def main(args=None):
|
||||||
# Which password we use is determined by your username, so we
|
# Which password we use is determined by your username, so we
|
||||||
# do need to check for this first and separately.
|
# do need to check for this first and separately.
|
||||||
if not username:
|
if not username:
|
||||||
parser.error('No username supplied')
|
parser.error("No username supplied")
|
||||||
|
|
||||||
if not password:
|
if not password:
|
||||||
password = utils.get_password(
|
password = utils.get_password(
|
||||||
username,
|
username, interactive=command_line.interactive
|
||||||
interactive=command_line.interactive
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not password:
|
if not password:
|
||||||
parser.error('No password supplied')
|
parser.error("No password supplied")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api = pyicloud.PyiCloudService(
|
api = PyiCloudService(username.strip(), password.strip())
|
||||||
username.strip(),
|
|
||||||
password.strip()
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
not utils.password_exists_in_keyring(username) and
|
not utils.password_exists_in_keyring(username)
|
||||||
command_line.interactive and
|
and command_line.interactive
|
||||||
confirm("Save password in keyring? ")
|
and confirm("Save password in keyring?")
|
||||||
):
|
):
|
||||||
utils.store_password_in_keyring(username, password)
|
utils.store_password_in_keyring(username, password)
|
||||||
|
|
||||||
if api.requires_2sa:
|
if api.requires_2fa:
|
||||||
import click
|
# fmt: off
|
||||||
print("Two-step authentication required.",
|
print(
|
||||||
"Your trusted devices are:")
|
"\nTwo-step authentication required.",
|
||||||
|
"\nPlease enter validation code"
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
code = input("(string) --> ")
|
||||||
|
if not api.validate_2fa_code(code):
|
||||||
|
print("Failed to verify verification code")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("")
|
||||||
|
|
||||||
|
elif api.requires_2sa:
|
||||||
|
# fmt: off
|
||||||
|
print(
|
||||||
|
"\nTwo-step authentication required.",
|
||||||
|
"\nYour trusted devices are:"
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
devices = api.trusted_devices
|
devices = api.trusted_devices
|
||||||
for i, device in enumerate(devices):
|
for i, device in enumerate(devices):
|
||||||
print(" %s: %s" % (
|
print(
|
||||||
i, device.get(
|
" %s: %s"
|
||||||
'deviceName',
|
% (
|
||||||
"SMS to %s" % device.get('phoneNumber'))))
|
i,
|
||||||
|
device.get(
|
||||||
|
"deviceName", "SMS to %s" % device.get("phoneNumber")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
device = click.prompt('Which device would you like to use?',
|
print("\nWhich device would you like to use?")
|
||||||
default=0)
|
device = int(input("(number) --> "))
|
||||||
device = devices[device]
|
device = devices[device]
|
||||||
if not api.send_verification_code(device):
|
if not api.send_verification_code(device):
|
||||||
print("Failed to send verification code")
|
print("Failed to send verification code")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
code = click.prompt('Please enter validation code')
|
print("\nPlease enter validation code")
|
||||||
|
code = input("(string) --> ")
|
||||||
if not api.validate_verification_code(device, code):
|
if not api.validate_verification_code(device, code):
|
||||||
print("Failed to verify verification code")
|
print("Failed to verify verification code")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("")
|
||||||
break
|
break
|
||||||
except pyicloud.exceptions.PyiCloudFailedLoginException:
|
except PyiCloudFailedLoginException as err:
|
||||||
# If they have a stored password; we just used it and
|
# If they have a stored password; we just used it and
|
||||||
# it did not work; let's delete it if there is one.
|
# it did not work; let's delete it if there is one.
|
||||||
if utils.password_exists_in_keyring(username):
|
if utils.password_exists_in_keyring(username):
|
||||||
|
@ -246,38 +259,32 @@ def main(args=None):
|
||||||
|
|
||||||
failure_count += 1
|
failure_count += 1
|
||||||
if failure_count >= 3:
|
if failure_count >= 3:
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message) from err
|
||||||
|
|
||||||
print(message, file=sys.stderr)
|
print(message, file=sys.stderr)
|
||||||
|
|
||||||
for dev in api.devices:
|
for dev in api.devices:
|
||||||
if (
|
if not command_line.device_id or (
|
||||||
not command_line.device_id or
|
command_line.device_id.strip().lower() == dev.content["id"].strip().lower()
|
||||||
(
|
|
||||||
command_line.device_id.strip().lower() ==
|
|
||||||
dev.content["id"].strip().lower()
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
# List device(s)
|
# List device(s)
|
||||||
if command_line.locate:
|
if command_line.locate:
|
||||||
dev.location()
|
dev.location()
|
||||||
|
|
||||||
if command_line.output_to_file:
|
if command_line.output_to_file:
|
||||||
create_pickled_data(
|
create_pickled_data(
|
||||||
dev,
|
dev,
|
||||||
filename=(
|
filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"),
|
||||||
dev.content["name"].strip().lower() + ".fmip_snapshot"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
contents = dev.content
|
contents = dev.content
|
||||||
if command_line.longlist:
|
if command_line.longlist:
|
||||||
print("-"*30)
|
print("-" * 30)
|
||||||
print(contents["name"])
|
print(contents["name"])
|
||||||
for x in contents:
|
for key in contents:
|
||||||
print("%20s - %s" % (x, contents[x]))
|
print("%20s - %s" % (key, contents[key]))
|
||||||
elif command_line.list:
|
elif command_line.list:
|
||||||
print("-"*30)
|
print("-" * 30)
|
||||||
print("Name - %s" % contents["name"])
|
print("Name - %s" % contents["name"])
|
||||||
print("Display Name - %s" % contents["deviceDisplayName"])
|
print("Display Name - %s" % contents["deviceDisplayName"])
|
||||||
print("Location - %s" % contents["location"])
|
print("Location - %s" % contents["location"])
|
||||||
|
@ -286,68 +293,70 @@ def main(args=None):
|
||||||
print("Device Class - %s" % contents["deviceClass"])
|
print("Device Class - %s" % contents["deviceClass"])
|
||||||
print("Device Model - %s" % contents["deviceModel"])
|
print("Device Model - %s" % contents["deviceModel"])
|
||||||
|
|
||||||
# Play a Sound on a device
|
# Play a Sound on a device
|
||||||
if command_line.sound:
|
if command_line.sound:
|
||||||
if command_line.device_id:
|
if command_line.device_id:
|
||||||
dev.play_sound()
|
dev.play_sound()
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"\n\n\t\t%s %s\n\n" % (
|
"\n\n\t\t%s %s\n\n"
|
||||||
|
% (
|
||||||
"Sounds can only be played on a singular device.",
|
"Sounds can only be played on a singular device.",
|
||||||
DEVICE_ERROR
|
DEVICE_ERROR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display a Message on the device
|
# Display a Message on the device
|
||||||
if command_line.message:
|
if command_line.message:
|
||||||
if command_line.device_id:
|
if command_line.device_id:
|
||||||
dev.display_message(
|
dev.display_message(
|
||||||
subject='A Message',
|
subject="A Message", message=command_line.message, sounds=True
|
||||||
message=command_line.message,
|
|
||||||
sounds=True
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"%s %s" % (
|
"%s %s"
|
||||||
"Messages can only be played "
|
% (
|
||||||
"on a singular device.",
|
"Messages can only be played on a singular device.",
|
||||||
DEVICE_ERROR
|
DEVICE_ERROR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display a Silent Message on the device
|
# Display a Silent Message on the device
|
||||||
if command_line.silentmessage:
|
if command_line.silentmessage:
|
||||||
if command_line.device_id:
|
if command_line.device_id:
|
||||||
dev.display_message(
|
dev.display_message(
|
||||||
subject='A Silent Message',
|
subject="A Silent Message",
|
||||||
message=command_line.silentmessage,
|
message=command_line.silentmessage,
|
||||||
sounds=False
|
sounds=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"%s %s" % (
|
"%s %s"
|
||||||
|
% (
|
||||||
"Silent Messages can only be played "
|
"Silent Messages can only be played "
|
||||||
"on a singular device.",
|
"on a singular device.",
|
||||||
DEVICE_ERROR
|
DEVICE_ERROR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enable Lost mode
|
# Enable Lost mode
|
||||||
if command_line.lostmode:
|
if command_line.lostmode:
|
||||||
if command_line.device_id:
|
if command_line.device_id:
|
||||||
dev.lost_device(
|
dev.lost_device(
|
||||||
number=command_line.lost_phone.strip(),
|
number=command_line.lost_phone.strip(),
|
||||||
text=command_line.lost_message.strip(),
|
text=command_line.lost_message.strip(),
|
||||||
newpasscode=command_line.lost_password.strip()
|
newpasscode=command_line.lost_password.strip(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"%s %s" % (
|
"%s %s"
|
||||||
"Lost Mode can only be activated "
|
% (
|
||||||
"on a singular device.",
|
"Lost Mode can only be activated on a singular device.",
|
||||||
DEVICE_ERROR
|
DEVICE_ERROR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
|
"""Library exceptions."""
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudException(Exception):
|
class PyiCloudException(Exception):
|
||||||
|
"""Generic iCloud exception."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# API
|
# API
|
||||||
class PyiCloudAPIResponseException(PyiCloudException):
|
class PyiCloudAPIResponseException(PyiCloudException):
|
||||||
|
"""iCloud response exception."""
|
||||||
def __init__(self, reason, code=None, retry=False):
|
def __init__(self, reason, code=None, retry=False):
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.code = code
|
self.code = code
|
||||||
|
@ -14,28 +18,33 @@ class PyiCloudAPIResponseException(PyiCloudException):
|
||||||
if retry:
|
if retry:
|
||||||
message += ". Retrying ..."
|
message += ". Retrying ..."
|
||||||
|
|
||||||
super(PyiCloudAPIResponseException, self).__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException):
|
class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException):
|
||||||
|
"""iCloud service not activated exception."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Login
|
# Login
|
||||||
class PyiCloudFailedLoginException(PyiCloudException):
|
class PyiCloudFailedLoginException(PyiCloudException):
|
||||||
|
"""iCloud failed login exception."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PyiCloud2SARequiredException(PyiCloudException):
|
class PyiCloud2SARequiredException(PyiCloudException):
|
||||||
|
"""iCloud 2SA required exception."""
|
||||||
def __init__(self, apple_id):
|
def __init__(self, apple_id):
|
||||||
message = "Two-step authentication required for account: %s" % apple_id
|
message = "Two-step authentication required for account: %s" % apple_id
|
||||||
super(PyiCloud2SARequiredException, self).__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class PyiCloudNoStoredPasswordAvailableException(PyiCloudException):
|
class PyiCloudNoStoredPasswordAvailableException(PyiCloudException):
|
||||||
|
"""iCloud no stored password exception."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Webservice specific
|
# Webservice specific
|
||||||
class PyiCloudNoDevicesException(PyiCloudException):
|
class PyiCloudNoDevicesException(PyiCloudException):
|
||||||
|
"""iCloud no device exception."""
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
"""Services."""
|
||||||
from pyicloud.services.calendar import CalendarService
|
from pyicloud.services.calendar import CalendarService
|
||||||
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
|
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
|
||||||
from pyicloud.services.ubiquity import UbiquityService
|
from pyicloud.services.ubiquity import UbiquityService
|
||||||
|
@ -5,3 +6,4 @@ from pyicloud.services.contacts import ContactsService
|
||||||
from pyicloud.services.reminders import RemindersService
|
from pyicloud.services.reminders import RemindersService
|
||||||
from pyicloud.services.photos import PhotosService
|
from pyicloud.services.photos import PhotosService
|
||||||
from pyicloud.services.account import AccountService
|
from pyicloud.services.account import AccountService
|
||||||
|
from pyicloud.services.drive import DriveService
|
||||||
|
|
|
@ -1,55 +1,330 @@
|
||||||
import sys
|
"""Account service."""
|
||||||
|
from collections import OrderedDict
|
||||||
import six
|
|
||||||
|
|
||||||
from pyicloud.utils import underscore_to_camelcase
|
from pyicloud.utils import underscore_to_camelcase
|
||||||
|
|
||||||
|
|
||||||
class AccountService(object):
|
class AccountService:
|
||||||
|
"""The 'Account' iCloud service."""
|
||||||
|
|
||||||
def __init__(self, service_root, session, params):
|
def __init__(self, service_root, session, params):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = params
|
self.params = params
|
||||||
self._service_root = service_root
|
self._service_root = service_root
|
||||||
|
|
||||||
self._devices = []
|
self._devices = []
|
||||||
|
self._family = []
|
||||||
|
self._storage = None
|
||||||
|
|
||||||
self._acc_endpoint = '%s/setup/web/device' % self._service_root
|
self._acc_endpoint = "%s/setup/web" % self._service_root
|
||||||
self._account_devices_url = '%s/getDevices' % self._acc_endpoint
|
self._acc_devices_url = "%s/device/getDevices" % self._acc_endpoint
|
||||||
|
self._acc_family_details_url = "%s/family/getFamilyDetails" % self._acc_endpoint
|
||||||
req = self.session.get(self._account_devices_url, params=self.params)
|
self._acc_family_member_photo_url = (
|
||||||
self.response = req.json()
|
"%s/family/getMemberPhoto" % self._acc_endpoint
|
||||||
|
)
|
||||||
for device_info in self.response['devices']:
|
self._acc_storage_url = "https://setup.icloud.com/setup/ws/1/storageUsageInfo"
|
||||||
# device_id = device_info['udid']
|
|
||||||
# self._devices[device_id] = AccountDevice(device_info)
|
|
||||||
self._devices.append(AccountDevice(device_info))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def devices(self):
|
def devices(self):
|
||||||
|
"""Returns current paired devices."""
|
||||||
|
if not self._devices:
|
||||||
|
req = self.session.get(self._acc_devices_url, params=self.params)
|
||||||
|
response = req.json()
|
||||||
|
|
||||||
|
for device_info in response["devices"]:
|
||||||
|
self._devices.append(AccountDevice(device_info))
|
||||||
|
|
||||||
return self._devices
|
return self._devices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family(self):
|
||||||
|
"""Returns family members."""
|
||||||
|
if not self._family:
|
||||||
|
req = self.session.get(self._acc_family_details_url, params=self.params)
|
||||||
|
response = req.json()
|
||||||
|
|
||||||
@six.python_2_unicode_compatible
|
for member_info in response["familyMembers"]:
|
||||||
class AccountDevice(dict):
|
self._family.append(
|
||||||
def __init__(self, device_info):
|
FamilyMember(
|
||||||
super(AccountDevice, self).__init__(device_info)
|
member_info,
|
||||||
|
self.session,
|
||||||
|
self.params,
|
||||||
|
self._acc_family_member_photo_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def __getattr__(self, name):
|
return self._family
|
||||||
try:
|
|
||||||
return self[underscore_to_camelcase(name)]
|
@property
|
||||||
except KeyError:
|
def storage(self):
|
||||||
raise AttributeError(name)
|
"""Returns storage infos."""
|
||||||
|
if not self._storage:
|
||||||
|
req = self.session.get(self._acc_storage_url, params=self.params)
|
||||||
|
response = req.json()
|
||||||
|
|
||||||
|
self._storage = AccountStorage(response)
|
||||||
|
|
||||||
|
return self._storage
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return u"{display_name}: {name}".format(
|
return "{{devices: {}, family: {}, storage: {} bytes free}}".format(
|
||||||
display_name=self.model_display_name,
|
len(self.devices),
|
||||||
name=self.name,
|
len(self.family),
|
||||||
|
self.storage.usage.available_storage_in_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<{display}>'.format(
|
return f"<{type(self).__name__}: {self}>"
|
||||||
display=(
|
|
||||||
six.text_type(self)
|
|
||||||
if sys.version_info[0] >= 3 else
|
class AccountDevice(dict):
|
||||||
six.text_type(self).encode('utf8', 'replace')
|
"""Account device."""
|
||||||
)
|
|
||||||
|
def __getattr__(self, key):
|
||||||
|
return self[underscore_to_camelcase(key)]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{{model: {self.model_display_name}, name: {self.name}}}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {self}>"
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMember:
|
||||||
|
"""A family member."""
|
||||||
|
|
||||||
|
def __init__(self, member_info, session, params, acc_family_member_photo_url):
|
||||||
|
self._attrs = member_info
|
||||||
|
self._session = session
|
||||||
|
self._params = params
|
||||||
|
self._acc_family_member_photo_url = acc_family_member_photo_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_name(self):
|
||||||
|
"""Gets the last name."""
|
||||||
|
return self._attrs.get("lastName")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dsid(self):
|
||||||
|
"""Gets the dsid."""
|
||||||
|
return self._attrs.get("dsid")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_invitation_email(self):
|
||||||
|
"""Gets the original invitation."""
|
||||||
|
return self._attrs.get("originalInvitationEmail")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
"""Gets the full name."""
|
||||||
|
return self._attrs.get("fullName")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_classification(self):
|
||||||
|
"""Gets the age classification."""
|
||||||
|
return self._attrs.get("ageClassification")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apple_id_for_purchases(self):
|
||||||
|
"""Gets the apple id for purchases."""
|
||||||
|
return self._attrs.get("appleIdForPurchases")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apple_id(self):
|
||||||
|
"""Gets the apple id."""
|
||||||
|
return self._attrs.get("appleId")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family_id(self):
|
||||||
|
"""Gets the family id."""
|
||||||
|
return self._attrs.get("familyId")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_name(self):
|
||||||
|
"""Gets the first name."""
|
||||||
|
return self._attrs.get("firstName")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_parental_privileges(self):
|
||||||
|
"""Has parental privileges."""
|
||||||
|
return self._attrs.get("hasParentalPrivileges")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_screen_time_enabled(self):
|
||||||
|
"""Has screen time enabled."""
|
||||||
|
return self._attrs.get("hasScreenTimeEnabled")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_ask_to_buy_enabled(self):
|
||||||
|
"""Has to ask for buying."""
|
||||||
|
return self._attrs.get("hasAskToBuyEnabled")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_share_purchases_enabled(self):
|
||||||
|
"""Has share purshases."""
|
||||||
|
return self._attrs.get("hasSharePurchasesEnabled")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def share_my_location_enabled_family_members(self):
|
||||||
|
"""Has share my location with family."""
|
||||||
|
return self._attrs.get("shareMyLocationEnabledFamilyMembers")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_share_my_location_enabled(self):
|
||||||
|
"""Has share my location."""
|
||||||
|
return self._attrs.get("hasShareMyLocationEnabled")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dsid_for_purchases(self):
|
||||||
|
"""Gets the dsid for purchases."""
|
||||||
|
return self._attrs.get("dsidForPurchases")
|
||||||
|
|
||||||
|
def get_photo(self):
|
||||||
|
"""Returns the photo."""
|
||||||
|
params_photo = dict(self._params)
|
||||||
|
params_photo.update({"memberId": self.dsid})
|
||||||
|
return self._session.get(
|
||||||
|
self._acc_family_member_photo_url, params=params_photo, stream=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if self._attrs.get(key):
|
||||||
|
return self._attrs[key]
|
||||||
|
return getattr(self, key)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{{name: {}, age_classification: {}}}".format(
|
||||||
|
self.full_name,
|
||||||
|
self.age_classification,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {self}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStorageUsageForMedia:
|
||||||
|
"""Storage used for a specific media type into the account."""
|
||||||
|
|
||||||
|
def __init__(self, usage_data):
|
||||||
|
self.usage_data = usage_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self):
|
||||||
|
"""Gets the key."""
|
||||||
|
return self.usage_data["mediaKey"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
"""Gets the label."""
|
||||||
|
return self.usage_data["displayLabel"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self):
|
||||||
|
"""Gets the HEX color."""
|
||||||
|
return self.usage_data["displayColor"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_in_bytes(self):
|
||||||
|
"""Gets the usage in bytes."""
|
||||||
|
return self.usage_data["usageInBytes"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{{key: {self.key}, usage: {self.usage_in_bytes} bytes}}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {self}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStorageUsage:
|
||||||
|
"""Storage used for a specific media type into the account."""
|
||||||
|
|
||||||
|
def __init__(self, usage_data, quota_data):
|
||||||
|
self.usage_data = usage_data
|
||||||
|
self.quota_data = quota_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def comp_storage_in_bytes(self):
|
||||||
|
"""Gets the comp storage in bytes."""
|
||||||
|
return self.usage_data["compStorageInBytes"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_storage_in_bytes(self):
|
||||||
|
"""Gets the used storage in bytes."""
|
||||||
|
return self.usage_data["usedStorageInBytes"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_storage_in_percent(self):
|
||||||
|
"""Gets the used storage in percent."""
|
||||||
|
return round(self.used_storage_in_bytes * 100 / self.total_storage_in_bytes, 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_storage_in_bytes(self):
|
||||||
|
"""Gets the available storage in bytes."""
|
||||||
|
return self.total_storage_in_bytes - self.used_storage_in_bytes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_storage_in_percent(self):
|
||||||
|
"""Gets the available storage in percent."""
|
||||||
|
return round(
|
||||||
|
self.available_storage_in_bytes * 100 / self.total_storage_in_bytes, 2
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_storage_in_bytes(self):
|
||||||
|
"""Gets the total storage in bytes."""
|
||||||
|
return self.usage_data["totalStorageInBytes"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commerce_storage_in_bytes(self):
|
||||||
|
"""Gets the commerce storage in bytes."""
|
||||||
|
return self.usage_data["commerceStorageInBytes"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_over(self):
|
||||||
|
"""Gets the over quota."""
|
||||||
|
return self.quota_data["overQuota"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_tier_max(self):
|
||||||
|
"""Gets the max tier quota."""
|
||||||
|
return self.quota_data["haveMaxQuotaTier"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_almost_full(self):
|
||||||
|
"""Gets the almost full quota."""
|
||||||
|
return self.quota_data["almost-full"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_paid(self):
|
||||||
|
"""Gets the paid quota."""
|
||||||
|
return self.quota_data["paidQuota"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{}% used of {} bytes".format(
|
||||||
|
self.used_storage_in_percent,
|
||||||
|
self.total_storage_in_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {self}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStorage:
|
||||||
|
"""Storage of the account."""
|
||||||
|
|
||||||
|
def __init__(self, storage_data):
|
||||||
|
self.usage = AccountStorageUsage(
|
||||||
|
storage_data.get("storageUsageInfo"), storage_data.get("quotaStatus")
|
||||||
|
)
|
||||||
|
self.usages_by_media = OrderedDict()
|
||||||
|
|
||||||
|
for usage_media in storage_data.get("storageUsageByMedia"):
|
||||||
|
self.usages_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia(
|
||||||
|
usage_media
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{{usage: {self.usage}, usages_by_media: {self.usages_by_media}}}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {self}>"
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
from __future__ import absolute_import
|
"""Calendar service."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
import time
|
|
||||||
|
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone_name
|
||||||
|
|
||||||
|
|
||||||
class CalendarService(object):
|
class CalendarService:
|
||||||
"""
|
"""
|
||||||
The 'Calendar' iCloud service, connects to iCloud and returns events.
|
The 'Calendar' iCloud service, connects to iCloud and returns events.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, service_root, session, params):
|
def __init__(self, service_root, session, params):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = params
|
self.params = params
|
||||||
self._service_root = service_root
|
self._service_root = service_root
|
||||||
self._calendar_endpoint = '%s/ca' % self._service_root
|
self._calendar_endpoint = "%s/ca" % self._service_root
|
||||||
self._calendar_refresh_url = '%s/events' % self._calendar_endpoint
|
self._calendar_refresh_url = "%s/events" % self._calendar_endpoint
|
||||||
self._calendar_event_detail_url = '%s/eventdetail' % (
|
self._calendar_event_detail_url = f"{self._calendar_endpoint}/eventdetail"
|
||||||
self._calendar_endpoint,
|
self._calendars = "%s/startup" % self._calendar_endpoint
|
||||||
)
|
|
||||||
self._calendars = '%s/startup' % self._calendar_endpoint
|
self.response = {}
|
||||||
|
|
||||||
def get_event_detail(self, pguid, guid):
|
def get_event_detail(self, pguid, guid):
|
||||||
"""
|
"""
|
||||||
|
@ -27,11 +27,11 @@ class CalendarService(object):
|
||||||
(a calendar) and a guid (an event's ID).
|
(a calendar) and a guid (an event's ID).
|
||||||
"""
|
"""
|
||||||
params = dict(self.params)
|
params = dict(self.params)
|
||||||
params.update({'lang': 'en-us', 'usertz': get_localzone().zone})
|
params.update({"lang": "en-us", "usertz": get_localzone_name()})
|
||||||
url = '%s/%s/%s' % (self._calendar_event_detail_url, pguid, guid)
|
url = f"{self._calendar_event_detail_url}/{pguid}/{guid}"
|
||||||
req = self.session.get(url, params=params)
|
req = self.session.get(url, params=params)
|
||||||
self.response = req.json()
|
self.response = req.json()
|
||||||
return self.response['Event'][0]
|
return self.response["Event"][0]
|
||||||
|
|
||||||
def refresh_client(self, from_dt=None, to_dt=None):
|
def refresh_client(self, from_dt=None, to_dt=None):
|
||||||
"""
|
"""
|
||||||
|
@ -46,12 +46,14 @@ class CalendarService(object):
|
||||||
if not to_dt:
|
if not to_dt:
|
||||||
to_dt = datetime(today.year, today.month, last_day)
|
to_dt = datetime(today.year, today.month, last_day)
|
||||||
params = dict(self.params)
|
params = dict(self.params)
|
||||||
params.update({
|
params.update(
|
||||||
'lang': 'en-us',
|
{
|
||||||
'usertz': get_localzone().zone,
|
"lang": "en-us",
|
||||||
'startDate': from_dt.strftime('%Y-%m-%d'),
|
"usertz": get_localzone_name(),
|
||||||
'endDate': to_dt.strftime('%Y-%m-%d')
|
"startDate": from_dt.strftime("%Y-%m-%d"),
|
||||||
})
|
"endDate": to_dt.strftime("%Y-%m-%d"),
|
||||||
|
}
|
||||||
|
)
|
||||||
req = self.session.get(self._calendar_refresh_url, params=params)
|
req = self.session.get(self._calendar_refresh_url, params=params)
|
||||||
self.response = req.json()
|
self.response = req.json()
|
||||||
|
|
||||||
|
@ -60,23 +62,25 @@ class CalendarService(object):
|
||||||
Retrieves events for a given date range, by default, this month.
|
Retrieves events for a given date range, by default, this month.
|
||||||
"""
|
"""
|
||||||
self.refresh_client(from_dt, to_dt)
|
self.refresh_client(from_dt, to_dt)
|
||||||
return self.response['Event']
|
return self.response.get("Event")
|
||||||
|
|
||||||
def calendars(self):
|
def calendars(self):
|
||||||
"""
|
"""
|
||||||
Retrieves calendars for this month
|
Retrieves calendars of this month.
|
||||||
"""
|
"""
|
||||||
today = datetime.today()
|
today = datetime.today()
|
||||||
first_day, last_day = monthrange(today.year, today.month)
|
first_day, last_day = monthrange(today.year, today.month)
|
||||||
from_dt = datetime(today.year, today.month, first_day)
|
from_dt = datetime(today.year, today.month, first_day)
|
||||||
to_dt = datetime(today.year, today.month, last_day)
|
to_dt = datetime(today.year, today.month, last_day)
|
||||||
params = dict(self.params)
|
params = dict(self.params)
|
||||||
params.update({
|
params.update(
|
||||||
'lang': 'en-us',
|
{
|
||||||
'usertz': get_localzone().zone,
|
"lang": "en-us",
|
||||||
'startDate': from_dt.strftime('%Y-%m-%d'),
|
"usertz": get_localzone_name(),
|
||||||
'endDate': to_dt.strftime('%Y-%m-%d')
|
"startDate": from_dt.strftime("%Y-%m-%d"),
|
||||||
})
|
"endDate": to_dt.strftime("%Y-%m-%d"),
|
||||||
|
}
|
||||||
|
)
|
||||||
req = self.session.get(self._calendars, params=params)
|
req = self.session.get(self._calendars, params=params)
|
||||||
self.response = req.json()
|
self.response = req.json()
|
||||||
return self.response['Collection']
|
return self.response["Collection"]
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
from __future__ import absolute_import
|
"""Contacts service."""
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from calendar import monthrange
|
|
||||||
|
|
||||||
|
|
||||||
class ContactsService(object):
|
class ContactsService:
|
||||||
"""
|
"""
|
||||||
The 'Contacts' iCloud service, connects to iCloud and returns contacts.
|
The 'Contacts' iCloud service, connects to iCloud and returns contacts.
|
||||||
"""
|
"""
|
||||||
|
@ -14,9 +10,12 @@ class ContactsService(object):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = params
|
self.params = params
|
||||||
self._service_root = service_root
|
self._service_root = service_root
|
||||||
self._contacts_endpoint = '%s/co' % self._service_root
|
self._contacts_endpoint = "%s/co" % self._service_root
|
||||||
self._contacts_refresh_url = '%s/startup' % self._contacts_endpoint
|
self._contacts_refresh_url = "%s/startup" % self._contacts_endpoint
|
||||||
self._contacts_changeset_url = '%s/changeset' % self._contacts_endpoint
|
self._contacts_next_url = "%s/contacts" % self._contacts_endpoint
|
||||||
|
self._contacts_changeset_url = "%s/changeset" % self._contacts_endpoint
|
||||||
|
|
||||||
|
self.response = {}
|
||||||
|
|
||||||
def refresh_client(self):
|
def refresh_client(self):
|
||||||
"""
|
"""
|
||||||
|
@ -24,15 +23,26 @@ class ContactsService(object):
|
||||||
contacts data is up-to-date.
|
contacts data is up-to-date.
|
||||||
"""
|
"""
|
||||||
params_contacts = dict(self.params)
|
params_contacts = dict(self.params)
|
||||||
params_contacts.update({
|
params_contacts.update(
|
||||||
'clientVersion': '2.1',
|
{
|
||||||
'locale': 'en_US',
|
"clientVersion": "2.1",
|
||||||
'order': 'last,first',
|
"locale": "en_US",
|
||||||
})
|
"order": "last,first",
|
||||||
req = self.session.get(
|
}
|
||||||
self._contacts_refresh_url,
|
|
||||||
params=params_contacts
|
|
||||||
)
|
)
|
||||||
|
req = self.session.get(self._contacts_refresh_url, params=params_contacts)
|
||||||
|
self.response = req.json()
|
||||||
|
|
||||||
|
params_next = dict(params_contacts)
|
||||||
|
params_next.update(
|
||||||
|
{
|
||||||
|
"prefToken": self.response["prefToken"],
|
||||||
|
"syncToken": self.response["syncToken"],
|
||||||
|
"limit": "0",
|
||||||
|
"offset": "0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req = self.session.get(self._contacts_next_url, params=params_next)
|
||||||
self.response = req.json()
|
self.response = req.json()
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
|
@ -40,4 +50,4 @@ class ContactsService(object):
|
||||||
Retrieves all contacts.
|
Retrieves all contacts.
|
||||||
"""
|
"""
|
||||||
self.refresh_client()
|
self.refresh_client()
|
||||||
return self.response['contacts']
|
return self.response.get("contacts")
|
||||||
|
|
362
pyicloud/services/drive.py
Normal file
362
pyicloud/services/drive.py
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
"""Drive service."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import io
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from re import search
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
from pyicloud.exceptions import PyiCloudAPIResponseException
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DriveService:
|
||||||
|
"""The 'Drive' iCloud service."""
|
||||||
|
|
||||||
|
def __init__(self, service_root, document_root, session, params):
|
||||||
|
self._service_root = service_root
|
||||||
|
self._document_root = document_root
|
||||||
|
self.session = session
|
||||||
|
self.params = dict(params)
|
||||||
|
self._root = None
|
||||||
|
|
||||||
|
def _get_token_from_cookie(self):
|
||||||
|
for cookie in self.session.cookies:
|
||||||
|
if cookie.name == "X-APPLE-WEBAUTH-VALIDATE":
|
||||||
|
match = search(r"\bt=([^:]+)", cookie.value)
|
||||||
|
if match is None:
|
||||||
|
raise Exception("Can't extract token from %r" % cookie.value)
|
||||||
|
return {"token": match.group(1)}
|
||||||
|
raise Exception("Token cookie not found")
|
||||||
|
|
||||||
|
def get_node_data(self, node_id):
|
||||||
|
"""Returns the node data."""
|
||||||
|
request = self.session.post(
|
||||||
|
self._service_root + "/retrieveItemDetailsInFolders",
|
||||||
|
params=self.params,
|
||||||
|
data=json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::%s" % node_id,
|
||||||
|
"partialData": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()[0]
|
||||||
|
|
||||||
|
def get_file(self, file_id, **kwargs):
|
||||||
|
"""Returns iCloud Drive file."""
|
||||||
|
file_params = dict(self.params)
|
||||||
|
file_params.update({"document_id": file_id})
|
||||||
|
response = self.session.get(
|
||||||
|
self._document_root + "/ws/com.apple.CloudDocs/download/by_id",
|
||||||
|
params=file_params,
|
||||||
|
)
|
||||||
|
self._raise_if_error(response)
|
||||||
|
response_json = response.json()
|
||||||
|
package_token = response_json.get("package_token")
|
||||||
|
data_token = response_json.get("data_token")
|
||||||
|
if data_token and data_token.get("url"):
|
||||||
|
return self.session.get(data_token["url"], params=self.params, **kwargs)
|
||||||
|
if package_token and package_token.get("url"):
|
||||||
|
return self.session.get(package_token["url"], params=self.params, **kwargs)
|
||||||
|
raise KeyError("'data_token' nor 'package_token'")
|
||||||
|
|
||||||
|
def get_app_data(self):
|
||||||
|
"""Returns the app library (previously ubiquity)."""
|
||||||
|
request = self.session.get(
|
||||||
|
self._service_root + "/retrieveAppLibraries", params=self.params
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()["items"]
|
||||||
|
|
||||||
|
def _get_upload_contentws_url(self, file_object):
|
||||||
|
"""Get the contentWS endpoint URL to add a new file."""
|
||||||
|
content_type = mimetypes.guess_type(file_object.name)[0]
|
||||||
|
if content_type is None:
|
||||||
|
content_type = ""
|
||||||
|
|
||||||
|
# Get filesize from file object
|
||||||
|
orig_pos = file_object.tell()
|
||||||
|
file_object.seek(0, os.SEEK_END)
|
||||||
|
file_size = file_object.tell()
|
||||||
|
file_object.seek(orig_pos, os.SEEK_SET)
|
||||||
|
|
||||||
|
file_params = self.params
|
||||||
|
file_params.update(self._get_token_from_cookie())
|
||||||
|
|
||||||
|
request = self.session.post(
|
||||||
|
self._document_root + "/ws/com.apple.CloudDocs/upload/web",
|
||||||
|
params=file_params,
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"filename": file_object.name,
|
||||||
|
"type": "FILE",
|
||||||
|
"content_type": content_type,
|
||||||
|
"size": file_size,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return (request.json()[0]["document_id"], request.json()[0]["url"])
|
||||||
|
|
||||||
|
def _update_contentws(self, folder_id, sf_info, document_id, file_object):
|
||||||
|
data = {
|
||||||
|
"data": {
|
||||||
|
"signature": sf_info["fileChecksum"],
|
||||||
|
"wrapping_key": sf_info["wrappingKey"],
|
||||||
|
"reference_signature": sf_info["referenceChecksum"],
|
||||||
|
"size": sf_info["size"],
|
||||||
|
},
|
||||||
|
"command": "add_file",
|
||||||
|
"create_short_guid": True,
|
||||||
|
"document_id": document_id,
|
||||||
|
"path": {
|
||||||
|
"starting_document_id": folder_id,
|
||||||
|
"path": file_object.name,
|
||||||
|
},
|
||||||
|
"allow_conflict": True,
|
||||||
|
"file_flags": {
|
||||||
|
"is_writable": True,
|
||||||
|
"is_executable": False,
|
||||||
|
"is_hidden": False,
|
||||||
|
},
|
||||||
|
"mtime": int(time.time() * 1000),
|
||||||
|
"btime": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the receipt if we have one. Will be absent for 0-sized files
|
||||||
|
if sf_info.get("receipt"):
|
||||||
|
data["data"].update({"receipt": sf_info["receipt"]})
|
||||||
|
|
||||||
|
request = self.session.post(
|
||||||
|
self._document_root + "/ws/com.apple.CloudDocs/update/documents",
|
||||||
|
params=self.params,
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
data=json.dumps(data),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()
|
||||||
|
|
||||||
|
def send_file(self, folder_id, file_object):
|
||||||
|
"""Send new file to iCloud Drive."""
|
||||||
|
document_id, content_url = self._get_upload_contentws_url(file_object)
|
||||||
|
|
||||||
|
request = self.session.post(content_url, files={file_object.name: file_object})
|
||||||
|
self._raise_if_error(request)
|
||||||
|
content_response = request.json()["singleFile"]
|
||||||
|
self._update_contentws(folder_id, content_response, document_id, file_object)
|
||||||
|
|
||||||
|
def create_folders(self, parent, name):
|
||||||
|
"""Creates a new iCloud Drive folder"""
|
||||||
|
request = self.session.post(
|
||||||
|
self._service_root + "/createFolders",
|
||||||
|
params=self.params,
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"destinationDrivewsId": parent,
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"clientId": self.params["clientId"],
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()
|
||||||
|
|
||||||
|
def rename_items(self, node_id, etag, name):
|
||||||
|
"""Renames an iCloud Drive node"""
|
||||||
|
request = self.session.post(
|
||||||
|
self._service_root + "/renameItems",
|
||||||
|
params=self.params,
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"drivewsid": node_id,
|
||||||
|
"etag": etag,
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()
|
||||||
|
|
||||||
|
def move_items_to_trash(self, node_id, etag):
|
||||||
|
"""Moves an iCloud Drive node to the trash bin"""
|
||||||
|
request = self.session.post(
|
||||||
|
self._service_root + "/moveItemsToTrash",
|
||||||
|
params=self.params,
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"drivewsid": node_id,
|
||||||
|
"etag": etag,
|
||||||
|
"clientId": self.params["clientId"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._raise_if_error(request)
|
||||||
|
return request.json()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root(self):
|
||||||
|
"""Returns the root node."""
|
||||||
|
if not self._root:
|
||||||
|
self._root = DriveNode(self, self.get_node_data("root"))
|
||||||
|
return self._root
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self.root, attr)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.root[key]
|
||||||
|
|
||||||
|
def _raise_if_error(self, response): # pylint: disable=no-self-use
|
||||||
|
if not response.ok:
|
||||||
|
api_error = PyiCloudAPIResponseException(
|
||||||
|
response.reason, response.status_code
|
||||||
|
)
|
||||||
|
LOGGER.error(api_error)
|
||||||
|
raise api_error
|
||||||
|
|
||||||
|
|
||||||
|
class DriveNode:
|
||||||
|
"""Drive node."""
|
||||||
|
|
||||||
|
def __init__(self, conn, data):
|
||||||
|
self.data = data
|
||||||
|
self.connection = conn
|
||||||
|
self._children = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Gets the node name."""
|
||||||
|
if "extension" in self.data:
|
||||||
|
return "{}.{}".format(self.data["name"], self.data["extension"])
|
||||||
|
return self.data["name"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
"""Gets the node type."""
|
||||||
|
node_type = self.data.get("type")
|
||||||
|
return node_type and node_type.lower()
|
||||||
|
|
||||||
|
def get_children(self):
|
||||||
|
"""Gets the node children."""
|
||||||
|
if not self._children:
|
||||||
|
if "items" not in self.data:
|
||||||
|
self.data.update(self.connection.get_node_data(self.data["docwsid"]))
|
||||||
|
if "items" not in self.data:
|
||||||
|
raise KeyError("No items in folder, status: %s" % self.data["status"])
|
||||||
|
self._children = [
|
||||||
|
DriveNode(self.connection, item_data)
|
||||||
|
for item_data in self.data["items"]
|
||||||
|
]
|
||||||
|
return self._children
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self):
|
||||||
|
"""Gets the node size."""
|
||||||
|
size = self.data.get("size") # Folder does not have size
|
||||||
|
if not size:
|
||||||
|
return None
|
||||||
|
return int(size)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date_changed(self):
|
||||||
|
"""Gets the node changed date (in UTC)."""
|
||||||
|
return _date_to_utc(self.data.get("dateChanged")) # Folder does not have date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date_modified(self):
|
||||||
|
"""Gets the node modified date (in UTC)."""
|
||||||
|
return _date_to_utc(self.data.get("dateModified")) # Folder does not have date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date_last_open(self):
|
||||||
|
"""Gets the node last open date (in UTC)."""
|
||||||
|
return _date_to_utc(self.data.get("lastOpenTime")) # Folder does not have date
|
||||||
|
|
||||||
|
def open(self, **kwargs):
|
||||||
|
"""Gets the node file."""
|
||||||
|
# iCloud returns 400 Bad Request for 0-byte files
|
||||||
|
if self.data["size"] == 0:
|
||||||
|
response = Response()
|
||||||
|
response.raw = io.BytesIO()
|
||||||
|
return response
|
||||||
|
return self.connection.get_file(self.data["docwsid"], **kwargs)
|
||||||
|
|
||||||
|
def upload(self, file_object, **kwargs):
|
||||||
|
"""Upload a new file."""
|
||||||
|
return self.connection.send_file(self.data["docwsid"], file_object, **kwargs)
|
||||||
|
|
||||||
|
def dir(self):
|
||||||
|
"""Gets the node list of directories."""
|
||||||
|
if self.type == "file":
|
||||||
|
return None
|
||||||
|
return [child.name for child in self.get_children()]
|
||||||
|
|
||||||
|
def mkdir(self, folder):
|
||||||
|
"""Create a new directory directory."""
|
||||||
|
return self.connection.create_folders(self.data["drivewsid"], folder)
|
||||||
|
|
||||||
|
def rename(self, name):
|
||||||
|
"""Rename an iCloud Drive item."""
|
||||||
|
return self.connection.rename_items(
|
||||||
|
self.data["drivewsid"], self.data["etag"], name
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete an iCloud Drive item."""
|
||||||
|
return self.connection.move_items_to_trash(
|
||||||
|
self.data["drivewsid"], self.data["etag"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, name):
|
||||||
|
"""Gets the node child."""
|
||||||
|
if self.type == "file":
|
||||||
|
return None
|
||||||
|
return [child for child in self.get_children() if child.name == name][0]
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
try:
|
||||||
|
return self.get(key)
|
||||||
|
except IndexError as i:
|
||||||
|
raise KeyError(f"No child named '{key}' exists") from i
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return rf"\{type: {self.type}, name: {self.name}\}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__}: {str(self)}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _date_to_utc(date):
|
||||||
|
if not date:
|
||||||
|
return None
|
||||||
|
# jump through hoops to return time in UTC rather than California time
|
||||||
|
match = search(r"^(.+?)([\+\-]\d+):(\d\d)$", date)
|
||||||
|
if not match:
|
||||||
|
# Already in UTC
|
||||||
|
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
base = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
|
||||||
|
diff = timedelta(hours=int(match.group(2)), minutes=int(match.group(3)))
|
||||||
|
return base - diff
|
|
@ -1,17 +1,14 @@
|
||||||
|
"""Find my iPhone service."""
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
|
|
||||||
import six
|
|
||||||
|
|
||||||
from pyicloud.exceptions import PyiCloudNoDevicesException
|
from pyicloud.exceptions import PyiCloudNoDevicesException
|
||||||
|
|
||||||
|
|
||||||
class FindMyiPhoneServiceManager(object):
|
class FindMyiPhoneServiceManager:
|
||||||
"""The 'Find my iPhone' iCloud service
|
"""The 'Find my iPhone' iCloud service
|
||||||
|
|
||||||
This connects to iCloud and return phone data including the near-realtime
|
This connects to iCloud and return phone data including the near-realtime
|
||||||
latitude and longitude.
|
latitude and longitude.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, service_root, session, params, with_family=False):
|
def __init__(self, service_root, session, params, with_family=False):
|
||||||
|
@ -19,11 +16,11 @@ class FindMyiPhoneServiceManager(object):
|
||||||
self.params = params
|
self.params = params
|
||||||
self.with_family = with_family
|
self.with_family = with_family
|
||||||
|
|
||||||
fmip_endpoint = '%s/fmipservice/client/web' % service_root
|
fmip_endpoint = "%s/fmipservice/client/web" % service_root
|
||||||
self._fmip_refresh_url = '%s/refreshClient' % fmip_endpoint
|
self._fmip_refresh_url = "%s/refreshClient" % fmip_endpoint
|
||||||
self._fmip_sound_url = '%s/playSound' % fmip_endpoint
|
self._fmip_sound_url = "%s/playSound" % fmip_endpoint
|
||||||
self._fmip_message_url = '%s/sendMessage' % fmip_endpoint
|
self._fmip_message_url = "%s/sendMessage" % fmip_endpoint
|
||||||
self._fmip_lost_url = '%s/lostDevice' % fmip_endpoint
|
self._fmip_lost_url = "%s/lostDevice" % fmip_endpoint
|
||||||
|
|
||||||
self._devices = {}
|
self._devices = {}
|
||||||
self.refresh_client()
|
self.refresh_client()
|
||||||
|
@ -39,18 +36,19 @@ class FindMyiPhoneServiceManager(object):
|
||||||
params=self.params,
|
params=self.params,
|
||||||
data=json.dumps(
|
data=json.dumps(
|
||||||
{
|
{
|
||||||
'clientContext': {
|
"clientContext": {
|
||||||
'fmly': self.with_family,
|
"fmly": self.with_family,
|
||||||
'shouldLocate': True,
|
"shouldLocate": True,
|
||||||
'selectedDevice': 'all',
|
"selectedDevice": "all",
|
||||||
|
"deviceListVersion": 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
self.response = req.json()
|
self.response = req.json()
|
||||||
|
|
||||||
for device_info in self.response['content']:
|
for device_info in self.response["content"]:
|
||||||
device_id = device_info['id']
|
device_id = device_info["id"]
|
||||||
if device_id not in self._devices:
|
if device_id not in self._devices:
|
||||||
self._devices[device_id] = AppleDevice(
|
self._devices[device_id] = AppleDevice(
|
||||||
device_info,
|
device_info,
|
||||||
|
@ -69,33 +67,31 @@ class FindMyiPhoneServiceManager(object):
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
if isinstance(key, int):
|
if isinstance(key, int):
|
||||||
if six.PY3:
|
key = list(self.keys())[key]
|
||||||
key = list(self.keys())[key]
|
|
||||||
else:
|
|
||||||
key = self.keys()[key]
|
|
||||||
return self._devices[key]
|
return self._devices[key]
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
return getattr(self._devices, attr)
|
return getattr(self._devices, attr)
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return six.text_type(self._devices)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
as_unicode = self.__unicode__()
|
return f"{self._devices}"
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
return as_unicode
|
|
||||||
else:
|
|
||||||
return as_unicode.encode('utf-8', 'ignore')
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return six.text_type(self)
|
return f"{self}"
|
||||||
|
|
||||||
|
|
||||||
class AppleDevice(object):
|
class AppleDevice:
|
||||||
|
"""Apple device."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, content, session, params, manager,
|
self,
|
||||||
sound_url=None, lost_url=None, message_url=None
|
content,
|
||||||
|
session,
|
||||||
|
params,
|
||||||
|
manager,
|
||||||
|
sound_url=None,
|
||||||
|
lost_url=None,
|
||||||
|
message_url=None,
|
||||||
):
|
):
|
||||||
self.content = content
|
self.content = content
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
@ -107,46 +103,43 @@ class AppleDevice(object):
|
||||||
self.message_url = message_url
|
self.message_url = message_url
|
||||||
|
|
||||||
def update(self, data):
|
def update(self, data):
|
||||||
|
"""Updates the device data."""
|
||||||
self.content = data
|
self.content = data
|
||||||
|
|
||||||
def location(self):
|
def location(self):
|
||||||
|
"""Updates the device location."""
|
||||||
self.manager.refresh_client()
|
self.manager.refresh_client()
|
||||||
return self.content['location']
|
return self.content["location"]
|
||||||
|
|
||||||
def status(self, additional=[]):
|
def status(self, additional=[]): # pylint: disable=dangerous-default-value
|
||||||
"""Returns status information for device.
|
"""Returns status information for device.
|
||||||
|
|
||||||
This returns only a subset of possible properties.
|
This returns only a subset of possible properties.
|
||||||
"""
|
"""
|
||||||
self.manager.refresh_client()
|
self.manager.refresh_client()
|
||||||
fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name']
|
fields = ["batteryLevel", "deviceDisplayName", "deviceStatus", "name"]
|
||||||
fields += additional
|
fields += additional
|
||||||
properties = {}
|
properties = {}
|
||||||
for field in fields:
|
for field in fields:
|
||||||
properties[field] = self.content.get(field)
|
properties[field] = self.content.get(field)
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
def play_sound(self, subject='Find My iPhone Alert'):
|
def play_sound(self, subject="Find My iPhone Alert"):
|
||||||
"""Send a request to the device to play a sound.
|
"""Send a request to the device to play a sound.
|
||||||
|
|
||||||
It's possible to pass a custom message by changing the `subject`.
|
It's possible to pass a custom message by changing the `subject`.
|
||||||
"""
|
"""
|
||||||
data = json.dumps({
|
data = json.dumps(
|
||||||
'device': self.content['id'],
|
{
|
||||||
'subject': subject,
|
"device": self.content["id"],
|
||||||
'clientContext': {
|
"subject": subject,
|
||||||
'fmly': True
|
"clientContext": {"fmly": True},
|
||||||
}
|
}
|
||||||
})
|
|
||||||
self.session.post(
|
|
||||||
self.sound_url,
|
|
||||||
params=self.params,
|
|
||||||
data=data
|
|
||||||
)
|
)
|
||||||
|
self.session.post(self.sound_url, params=self.params, data=data)
|
||||||
|
|
||||||
def display_message(
|
def display_message(
|
||||||
self, subject='Find My iPhone Alert', message="This is a note",
|
self, subject="Find My iPhone Alert", message="This is a note", sounds=False
|
||||||
sounds=False
|
|
||||||
):
|
):
|
||||||
"""Send a request to the device to play a sound.
|
"""Send a request to the device to play a sound.
|
||||||
|
|
||||||
|
@ -154,23 +147,17 @@ class AppleDevice(object):
|
||||||
"""
|
"""
|
||||||
data = json.dumps(
|
data = json.dumps(
|
||||||
{
|
{
|
||||||
'device': self.content['id'],
|
"device": self.content["id"],
|
||||||
'subject': subject,
|
"subject": subject,
|
||||||
'sound': sounds,
|
"sound": sounds,
|
||||||
'userText': True,
|
"userText": True,
|
||||||
'text': message
|
"text": message,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.session.post(
|
self.session.post(self.message_url, params=self.params, data=data)
|
||||||
self.message_url,
|
|
||||||
params=self.params,
|
|
||||||
data=data
|
|
||||||
)
|
|
||||||
|
|
||||||
def lost_device(
|
def lost_device(
|
||||||
self, number,
|
self, number, text="This iPhone has been lost. Please call me.", newpasscode=""
|
||||||
text='This iPhone has been lost. Please call me.',
|
|
||||||
newpasscode=""
|
|
||||||
):
|
):
|
||||||
"""Send a request to the device to trigger 'lost mode'.
|
"""Send a request to the device to trigger 'lost mode'.
|
||||||
|
|
||||||
|
@ -178,23 +165,22 @@ class AppleDevice(object):
|
||||||
been passed, then the person holding the device can call
|
been passed, then the person holding the device can call
|
||||||
the number without entering the passcode.
|
the number without entering the passcode.
|
||||||
"""
|
"""
|
||||||
data = json.dumps({
|
data = json.dumps(
|
||||||
'text': text,
|
{
|
||||||
'userText': True,
|
"text": text,
|
||||||
'ownerNbr': number,
|
"userText": True,
|
||||||
'lostModeEnabled': True,
|
"ownerNbr": number,
|
||||||
'trackingEnabled': True,
|
"lostModeEnabled": True,
|
||||||
'device': self.content['id'],
|
"trackingEnabled": True,
|
||||||
'passcode': newpasscode
|
"device": self.content["id"],
|
||||||
})
|
"passcode": newpasscode,
|
||||||
self.session.post(
|
}
|
||||||
self.lost_url,
|
|
||||||
params=self.params,
|
|
||||||
data=data
|
|
||||||
)
|
)
|
||||||
|
self.session.post(self.lost_url, params=self.params, data=data)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self):
|
def data(self):
|
||||||
|
"""Gets the device data."""
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
|
@ -203,20 +189,8 @@ class AppleDevice(object):
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
return getattr(self.content, attr)
|
return getattr(self.content, attr)
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
display_name = self['deviceDisplayName']
|
|
||||||
name = self['name']
|
|
||||||
return '%s: %s' % (
|
|
||||||
display_name,
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
as_unicode = self.__unicode__()
|
return f"{self['deviceDisplayName']}: {self['name']}"
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
return as_unicode
|
|
||||||
else:
|
|
||||||
return as_unicode.encode('utf-8', 'ignore')
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<AppleDevice(%s)>' % str(self)
|
return f"<AppleDevice({self})>"
|
||||||
|
|
|
@ -1,134 +1,123 @@
|
||||||
import sys
|
"""Photo service."""
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import base64
|
import base64
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pyicloud.exceptions import PyiCloudServiceNotActivatedException
|
from pyicloud.exceptions import PyiCloudServiceNotActivatedException
|
||||||
import pytz
|
|
||||||
|
|
||||||
from future.moves.urllib.parse import urlencode
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PhotosService(object):
|
class PhotosService:
|
||||||
"""The 'Photos' iCloud service."""
|
"""The 'Photos' iCloud service."""
|
||||||
|
|
||||||
SMART_FOLDERS = {
|
SMART_FOLDERS = {
|
||||||
"All Photos": {
|
"All Photos": {
|
||||||
"obj_type": "CPLAssetByAddedDate",
|
"obj_type": "CPLAssetByAddedDate",
|
||||||
"list_type": "CPLAssetAndMasterByAddedDate",
|
"list_type": "CPLAssetAndMasterByAddedDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": None
|
"query_filter": None,
|
||||||
},
|
},
|
||||||
"Time-lapse": {
|
"Time-lapse": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Timelapse",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Timelapse",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "TIMELAPSE"},
|
||||||
"value": "TIMELAPSE"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Videos": {
|
"Videos": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Video",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Video",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "VIDEO"},
|
||||||
"value": "VIDEO"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Slo-mo": {
|
"Slo-mo": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "SLOMO"},
|
||||||
"value": "SLOMO"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Bursts": {
|
"Bursts": {
|
||||||
"obj_type": "CPLAssetBurstStackAssetByAssetDate",
|
"obj_type": "CPLAssetBurstStackAssetByAssetDate",
|
||||||
"list_type": "CPLBurstStackAssetAndMasterByAssetDate",
|
"list_type": "CPLBurstStackAssetAndMasterByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": None
|
"query_filter": None,
|
||||||
},
|
},
|
||||||
"Favorites": {
|
"Favorites": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Favorite",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Favorite",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "FAVORITE"},
|
||||||
"value": "FAVORITE"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Panoramas": {
|
"Panoramas": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "PANORAMA"},
|
||||||
"value": "PANORAMA"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Screenshots": {
|
"Screenshots": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "SCREENSHOT"},
|
||||||
"value": "SCREENSHOT"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Live": {
|
"Live": {
|
||||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Live",
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Live",
|
||||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": [{
|
"query_filter": [
|
||||||
"fieldName": "smartAlbum",
|
{
|
||||||
"comparator": "EQUALS",
|
"fieldName": "smartAlbum",
|
||||||
"fieldValue": {
|
"comparator": "EQUALS",
|
||||||
"type": "STRING",
|
"fieldValue": {"type": "STRING", "value": "LIVE"},
|
||||||
"value": "LIVE"
|
|
||||||
}
|
}
|
||||||
}]
|
],
|
||||||
},
|
},
|
||||||
"Recently Deleted": {
|
"Recently Deleted": {
|
||||||
"obj_type": "CPLAssetDeletedByExpungedDate",
|
"obj_type": "CPLAssetDeletedByExpungedDate",
|
||||||
"list_type": "CPLAssetAndMasterDeletedByExpungedDate",
|
"list_type": "CPLAssetAndMasterDeletedByExpungedDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": None
|
"query_filter": None,
|
||||||
},
|
},
|
||||||
"Hidden": {
|
"Hidden": {
|
||||||
"obj_type": "CPLAssetHiddenByAssetDate",
|
"obj_type": "CPLAssetHiddenByAssetDate",
|
||||||
"list_type": "CPLAssetAndMasterHiddenByAssetDate",
|
"list_type": "CPLAssetAndMasterHiddenByAssetDate",
|
||||||
"direction": "ASCENDING",
|
"direction": "ASCENDING",
|
||||||
"query_filter": None
|
"query_filter": None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,35 +125,32 @@ class PhotosService(object):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = dict(params)
|
self.params = dict(params)
|
||||||
self._service_root = service_root
|
self._service_root = service_root
|
||||||
self._service_endpoint = \
|
self.service_endpoint = (
|
||||||
('%s/database/1/com.apple.photos.cloud/production/private'
|
"%s/database/1/com.apple.photos.cloud/production/private"
|
||||||
% self._service_root)
|
% self._service_root
|
||||||
|
)
|
||||||
|
|
||||||
self._albums = None
|
self._albums = None
|
||||||
|
|
||||||
self.params.update({
|
self.params.update({"remapEnums": True, "getCurrentSyncToken": True})
|
||||||
'remapEnums': True,
|
|
||||||
'getCurrentSyncToken': True
|
|
||||||
})
|
|
||||||
|
|
||||||
url = ('%s/records/query?%s' %
|
url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
|
||||||
(self._service_endpoint, urlencode(self.params)))
|
json_data = (
|
||||||
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
'{"query":{"recordType":"CheckIndexingState"},'
|
||||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
'"zoneID":{"zoneName":"PrimarySync"}}'
|
||||||
|
)
|
||||||
request = self.session.post(
|
request = self.session.post(
|
||||||
url,
|
url, data=json_data, headers={"Content-type": "text/plain"}
|
||||||
data=json_data,
|
|
||||||
headers={'Content-type': 'text/plain'}
|
|
||||||
)
|
)
|
||||||
response = request.json()
|
response = request.json()
|
||||||
indexing_state = response['records'][0]['fields']['state']['value']
|
indexing_state = response["records"][0]["fields"]["state"]["value"]
|
||||||
if indexing_state != 'FINISHED':
|
if indexing_state != "FINISHED":
|
||||||
raise PyiCloudServiceNotActivatedException(
|
raise PyiCloudServiceNotActivatedException(
|
||||||
'iCloud Photo Library not finished indexing. '
|
"iCloud Photo Library not finished indexing. "
|
||||||
'Please try again in a few minutes.'
|
"Please try again in a few minutes."
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Does syncToken ever change?
|
# TODO: Does syncToken ever change? # pylint: disable=fixme
|
||||||
# self.params.update({
|
# self.params.update({
|
||||||
# 'syncToken': response['syncToken'],
|
# 'syncToken': response['syncToken'],
|
||||||
# 'clientInstanceId': self.params.pop('clientId')
|
# 'clientInstanceId': self.params.pop('clientId')
|
||||||
|
@ -174,62 +160,86 @@ class PhotosService(object):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def albums(self):
|
def albums(self):
|
||||||
|
"""Returns photo albums."""
|
||||||
if not self._albums:
|
if not self._albums:
|
||||||
self._albums = {name: PhotoAlbum(self, name, **props)
|
self._albums = {
|
||||||
for (name, props) in self.SMART_FOLDERS.items()}
|
name: PhotoAlbum(self, name, **props)
|
||||||
|
for (name, props) in self.SMART_FOLDERS.items()
|
||||||
|
}
|
||||||
|
|
||||||
for folder in self._fetch_folders():
|
for folder in self._fetch_folders():
|
||||||
# FIXME: Handle subfolders
|
|
||||||
if folder['recordName'] == '----Root-Folder----' or \
|
# Skiping albums having null name, that can happen sometime
|
||||||
(folder['fields'].get('isDeleted') and
|
if "albumNameEnc" not in folder["fields"]:
|
||||||
folder['fields']['isDeleted']['value']):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
folder_id = folder['recordName']
|
# TODO: Handle subfolders # pylint: disable=fixme
|
||||||
folder_obj_type = \
|
if folder["recordName"] == "----Root-Folder----" or (
|
||||||
"CPLContainerRelationNotDeletedByAssetDate:%s" % folder_id
|
folder["fields"].get("isDeleted")
|
||||||
folder_name = base64.b64decode(
|
and folder["fields"]["isDeleted"]["value"]
|
||||||
folder['fields']['albumNameEnc']['value']).decode('utf-8')
|
):
|
||||||
query_filter = [{
|
continue
|
||||||
"fieldName": "parentId",
|
|
||||||
"comparator": "EQUALS",
|
|
||||||
"fieldValue": {
|
|
||||||
"type": "STRING",
|
|
||||||
"value": folder_id
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
|
|
||||||
album = PhotoAlbum(self, folder_name,
|
folder_id = folder["recordName"]
|
||||||
'CPLContainerRelationLiveByAssetDate',
|
folder_obj_type = (
|
||||||
folder_obj_type, 'ASCENDING', query_filter)
|
"CPLContainerRelationNotDeletedByAssetDate:%s" % folder_id
|
||||||
|
)
|
||||||
|
folder_name = base64.b64decode(
|
||||||
|
folder["fields"]["albumNameEnc"]["value"]
|
||||||
|
).decode("utf-8")
|
||||||
|
query_filter = [
|
||||||
|
{
|
||||||
|
"fieldName": "parentId",
|
||||||
|
"comparator": "EQUALS",
|
||||||
|
"fieldValue": {"type": "STRING", "value": folder_id},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
album = PhotoAlbum(
|
||||||
|
self,
|
||||||
|
folder_name,
|
||||||
|
"CPLContainerRelationLiveByAssetDate",
|
||||||
|
folder_obj_type,
|
||||||
|
"ASCENDING",
|
||||||
|
query_filter,
|
||||||
|
)
|
||||||
self._albums[folder_name] = album
|
self._albums[folder_name] = album
|
||||||
|
|
||||||
return self._albums
|
return self._albums
|
||||||
|
|
||||||
def _fetch_folders(self):
|
def _fetch_folders(self):
|
||||||
url = ('%s/records/query?%s' %
|
url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
|
||||||
(self._service_endpoint, urlencode(self.params)))
|
json_data = (
|
||||||
json_data = ('{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
'{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
||||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
'"zoneID":{"zoneName":"PrimarySync"}}'
|
||||||
|
)
|
||||||
|
|
||||||
request = self.session.post(
|
request = self.session.post(
|
||||||
url,
|
url, data=json_data, headers={"Content-type": "text/plain"}
|
||||||
data=json_data,
|
|
||||||
headers={'Content-type': 'text/plain'}
|
|
||||||
)
|
)
|
||||||
response = request.json()
|
response = request.json()
|
||||||
|
|
||||||
return response['records']
|
return response["records"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all(self):
|
def all(self):
|
||||||
return self.albums['All Photos']
|
"""Returns all photos."""
|
||||||
|
return self.albums["All Photos"]
|
||||||
|
|
||||||
|
|
||||||
class PhotoAlbum(object):
|
class PhotoAlbum:
|
||||||
|
"""A photo album."""
|
||||||
|
|
||||||
def __init__(self, service, name, list_type, obj_type, direction,
|
def __init__(
|
||||||
query_filter=None, page_size=100):
|
self,
|
||||||
|
service,
|
||||||
|
name,
|
||||||
|
list_type,
|
||||||
|
obj_type,
|
||||||
|
direction,
|
||||||
|
query_filter=None,
|
||||||
|
page_size=100,
|
||||||
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.service = service
|
self.service = service
|
||||||
self.list_type = list_type
|
self.list_type = list_type
|
||||||
|
@ -242,6 +252,7 @@ class PhotoAlbum(object):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self):
|
def title(self):
|
||||||
|
"""Gets the album name."""
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
|
@ -249,48 +260,74 @@ class PhotoAlbum(object):
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
if self._len is None:
|
if self._len is None:
|
||||||
url = ('%s/internal/records/query/batch?%s' %
|
url = "{}/internal/records/query/batch?{}".format(
|
||||||
(self.service._service_endpoint,
|
self.service.service_endpoint,
|
||||||
urlencode(self.service.params)))
|
urlencode(self.service.params),
|
||||||
|
)
|
||||||
request = self.service.session.post(
|
request = self.service.session.post(
|
||||||
url,
|
url,
|
||||||
data=json.dumps(self._count_query_gen(self.obj_type)),
|
data=json.dumps(
|
||||||
headers={'Content-type': 'text/plain'}
|
{
|
||||||
|
"batch": [
|
||||||
|
{
|
||||||
|
"resultsLimit": 1,
|
||||||
|
"query": {
|
||||||
|
"filterBy": {
|
||||||
|
"fieldName": "indexCountID",
|
||||||
|
"fieldValue": {
|
||||||
|
"type": "STRING_LIST",
|
||||||
|
"value": [self.obj_type],
|
||||||
|
},
|
||||||
|
"comparator": "IN",
|
||||||
|
},
|
||||||
|
"recordType": "HyperionIndexCountLookup",
|
||||||
|
},
|
||||||
|
"zoneWide": True,
|
||||||
|
"zoneID": {"zoneName": "PrimarySync"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headers={"Content-type": "text/plain"},
|
||||||
)
|
)
|
||||||
response = request.json()
|
response = request.json()
|
||||||
|
|
||||||
self._len = (response["batch"][0]["records"][0]["fields"]
|
self._len = response["batch"][0]["records"][0]["fields"]["itemCount"][
|
||||||
["itemCount"]["value"])
|
"value"
|
||||||
|
]
|
||||||
|
|
||||||
return self._len
|
return self._len
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def photos(self):
|
def photos(self):
|
||||||
|
"""Returns the album photos."""
|
||||||
if self.direction == "DESCENDING":
|
if self.direction == "DESCENDING":
|
||||||
offset = len(self) - 1
|
offset = len(self) - 1
|
||||||
else:
|
else:
|
||||||
offset = 0
|
offset = 0
|
||||||
|
|
||||||
while(True):
|
while True:
|
||||||
url = ('%s/records/query?' % self.service._service_endpoint) + \
|
url = ("%s/records/query?" % self.service.service_endpoint) + urlencode(
|
||||||
urlencode(self.service.params)
|
self.service.params
|
||||||
|
)
|
||||||
request = self.service.session.post(
|
request = self.service.session.post(
|
||||||
url,
|
url,
|
||||||
data=json.dumps(self._list_query_gen(
|
data=json.dumps(
|
||||||
offset, self.list_type, self.direction,
|
self._list_query_gen(
|
||||||
self.query_filter)),
|
offset, self.list_type, self.direction, self.query_filter
|
||||||
headers={'Content-type': 'text/plain'}
|
)
|
||||||
|
),
|
||||||
|
headers={"Content-type": "text/plain"},
|
||||||
)
|
)
|
||||||
response = request.json()
|
response = request.json()
|
||||||
|
|
||||||
asset_records = {}
|
asset_records = {}
|
||||||
master_records = []
|
master_records = []
|
||||||
for rec in response['records']:
|
for rec in response["records"]:
|
||||||
if rec['recordType'] == "CPLAsset":
|
if rec["recordType"] == "CPLAsset":
|
||||||
master_id = \
|
master_id = rec["fields"]["masterRef"]["value"]["recordName"]
|
||||||
rec['fields']['masterRef']['value']['recordName']
|
|
||||||
asset_records[master_id] = rec
|
asset_records[master_id] = rec
|
||||||
elif rec['recordType'] == "CPLMaster":
|
elif rec["recordType"] == "CPLMaster":
|
||||||
master_records.append(rec)
|
master_records.append(rec)
|
||||||
|
|
||||||
master_records_len = len(master_records)
|
master_records_len = len(master_records)
|
||||||
|
@ -301,119 +338,148 @@ class PhotoAlbum(object):
|
||||||
offset = offset + master_records_len
|
offset = offset + master_records_len
|
||||||
|
|
||||||
for master_record in master_records:
|
for master_record in master_records:
|
||||||
record_name = master_record['recordName']
|
record_name = master_record["recordName"]
|
||||||
yield PhotoAsset(self.service, master_record,
|
yield PhotoAsset(
|
||||||
asset_records[record_name])
|
self.service, master_record, asset_records[record_name]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
def _count_query_gen(self, obj_type):
|
|
||||||
query = {
|
|
||||||
u'batch': [{
|
|
||||||
u'resultsLimit': 1,
|
|
||||||
u'query': {
|
|
||||||
u'filterBy': {
|
|
||||||
u'fieldName': u'indexCountID',
|
|
||||||
u'fieldValue': {
|
|
||||||
u'type': u'STRING_LIST',
|
|
||||||
u'value': [
|
|
||||||
obj_type
|
|
||||||
]
|
|
||||||
},
|
|
||||||
u'comparator': u'IN'
|
|
||||||
},
|
|
||||||
u'recordType': u'HyperionIndexCountLookup'
|
|
||||||
},
|
|
||||||
u'zoneWide': True,
|
|
||||||
u'zoneID': {
|
|
||||||
u'zoneName': u'PrimarySync'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
def _list_query_gen(self, offset, list_type, direction, query_filter=None):
|
def _list_query_gen(self, offset, list_type, direction, query_filter=None):
|
||||||
query = {
|
query = {
|
||||||
u'query': {
|
"query": {
|
||||||
u'filterBy': [
|
"filterBy": [
|
||||||
{u'fieldName': u'startRank', u'fieldValue':
|
{
|
||||||
{u'type': u'INT64', u'value': offset},
|
"fieldName": "startRank",
|
||||||
u'comparator': u'EQUALS'},
|
"fieldValue": {"type": "INT64", "value": offset},
|
||||||
{u'fieldName': u'direction', u'fieldValue':
|
"comparator": "EQUALS",
|
||||||
{u'type': u'STRING', u'value': direction},
|
},
|
||||||
u'comparator': u'EQUALS'}
|
{
|
||||||
|
"fieldName": "direction",
|
||||||
|
"fieldValue": {"type": "STRING", "value": direction},
|
||||||
|
"comparator": "EQUALS",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
u'recordType': list_type
|
"recordType": list_type,
|
||||||
},
|
},
|
||||||
u'resultsLimit': self.page_size * 2,
|
"resultsLimit": self.page_size * 2,
|
||||||
u'desiredKeys': [
|
"desiredKeys": [
|
||||||
u'resJPEGFullWidth', u'resJPEGFullHeight',
|
"resJPEGFullWidth",
|
||||||
u'resJPEGFullFileType', u'resJPEGFullFingerprint',
|
"resJPEGFullHeight",
|
||||||
u'resJPEGFullRes', u'resJPEGLargeWidth',
|
"resJPEGFullFileType",
|
||||||
u'resJPEGLargeHeight', u'resJPEGLargeFileType',
|
"resJPEGFullFingerprint",
|
||||||
u'resJPEGLargeFingerprint', u'resJPEGLargeRes',
|
"resJPEGFullRes",
|
||||||
u'resJPEGMedWidth', u'resJPEGMedHeight',
|
"resJPEGLargeWidth",
|
||||||
u'resJPEGMedFileType', u'resJPEGMedFingerprint',
|
"resJPEGLargeHeight",
|
||||||
u'resJPEGMedRes', u'resJPEGThumbWidth',
|
"resJPEGLargeFileType",
|
||||||
u'resJPEGThumbHeight', u'resJPEGThumbFileType',
|
"resJPEGLargeFingerprint",
|
||||||
u'resJPEGThumbFingerprint', u'resJPEGThumbRes',
|
"resJPEGLargeRes",
|
||||||
u'resVidFullWidth', u'resVidFullHeight',
|
"resJPEGMedWidth",
|
||||||
u'resVidFullFileType', u'resVidFullFingerprint',
|
"resJPEGMedHeight",
|
||||||
u'resVidFullRes', u'resVidMedWidth', u'resVidMedHeight',
|
"resJPEGMedFileType",
|
||||||
u'resVidMedFileType', u'resVidMedFingerprint',
|
"resJPEGMedFingerprint",
|
||||||
u'resVidMedRes', u'resVidSmallWidth', u'resVidSmallHeight',
|
"resJPEGMedRes",
|
||||||
u'resVidSmallFileType', u'resVidSmallFingerprint',
|
"resJPEGThumbWidth",
|
||||||
u'resVidSmallRes', u'resSidecarWidth', u'resSidecarHeight',
|
"resJPEGThumbHeight",
|
||||||
u'resSidecarFileType', u'resSidecarFingerprint',
|
"resJPEGThumbFileType",
|
||||||
u'resSidecarRes', u'itemType', u'dataClassType',
|
"resJPEGThumbFingerprint",
|
||||||
u'filenameEnc', u'originalOrientation', u'resOriginalWidth',
|
"resJPEGThumbRes",
|
||||||
u'resOriginalHeight', u'resOriginalFileType',
|
"resVidFullWidth",
|
||||||
u'resOriginalFingerprint', u'resOriginalRes',
|
"resVidFullHeight",
|
||||||
u'resOriginalAltWidth', u'resOriginalAltHeight',
|
"resVidFullFileType",
|
||||||
u'resOriginalAltFileType', u'resOriginalAltFingerprint',
|
"resVidFullFingerprint",
|
||||||
u'resOriginalAltRes', u'resOriginalVidComplWidth',
|
"resVidFullRes",
|
||||||
u'resOriginalVidComplHeight', u'resOriginalVidComplFileType',
|
"resVidMedWidth",
|
||||||
u'resOriginalVidComplFingerprint', u'resOriginalVidComplRes',
|
"resVidMedHeight",
|
||||||
u'isDeleted', u'isExpunged', u'dateExpunged', u'remappedRef',
|
"resVidMedFileType",
|
||||||
u'recordName', u'recordType', u'recordChangeTag',
|
"resVidMedFingerprint",
|
||||||
u'masterRef', u'adjustmentRenderType', u'assetDate',
|
"resVidMedRes",
|
||||||
u'addedDate', u'isFavorite', u'isHidden', u'orientation',
|
"resVidSmallWidth",
|
||||||
u'duration', u'assetSubtype', u'assetSubtypeV2',
|
"resVidSmallHeight",
|
||||||
u'assetHDRType', u'burstFlags', u'burstFlagsExt', u'burstId',
|
"resVidSmallFileType",
|
||||||
u'captionEnc', u'locationEnc', u'locationV2Enc',
|
"resVidSmallFingerprint",
|
||||||
u'locationLatitude', u'locationLongitude', u'adjustmentType',
|
"resVidSmallRes",
|
||||||
u'timeZoneOffset', u'vidComplDurValue', u'vidComplDurScale',
|
"resSidecarWidth",
|
||||||
u'vidComplDispValue', u'vidComplDispScale',
|
"resSidecarHeight",
|
||||||
u'vidComplVisibilityState', u'customRenderedValue',
|
"resSidecarFileType",
|
||||||
u'containerId', u'itemId', u'position', u'isKeyAsset'
|
"resSidecarFingerprint",
|
||||||
|
"resSidecarRes",
|
||||||
|
"itemType",
|
||||||
|
"dataClassType",
|
||||||
|
"filenameEnc",
|
||||||
|
"originalOrientation",
|
||||||
|
"resOriginalWidth",
|
||||||
|
"resOriginalHeight",
|
||||||
|
"resOriginalFileType",
|
||||||
|
"resOriginalFingerprint",
|
||||||
|
"resOriginalRes",
|
||||||
|
"resOriginalAltWidth",
|
||||||
|
"resOriginalAltHeight",
|
||||||
|
"resOriginalAltFileType",
|
||||||
|
"resOriginalAltFingerprint",
|
||||||
|
"resOriginalAltRes",
|
||||||
|
"resOriginalVidComplWidth",
|
||||||
|
"resOriginalVidComplHeight",
|
||||||
|
"resOriginalVidComplFileType",
|
||||||
|
"resOriginalVidComplFingerprint",
|
||||||
|
"resOriginalVidComplRes",
|
||||||
|
"isDeleted",
|
||||||
|
"isExpunged",
|
||||||
|
"dateExpunged",
|
||||||
|
"remappedRef",
|
||||||
|
"recordName",
|
||||||
|
"recordType",
|
||||||
|
"recordChangeTag",
|
||||||
|
"masterRef",
|
||||||
|
"adjustmentRenderType",
|
||||||
|
"assetDate",
|
||||||
|
"addedDate",
|
||||||
|
"isFavorite",
|
||||||
|
"isHidden",
|
||||||
|
"orientation",
|
||||||
|
"duration",
|
||||||
|
"assetSubtype",
|
||||||
|
"assetSubtypeV2",
|
||||||
|
"assetHDRType",
|
||||||
|
"burstFlags",
|
||||||
|
"burstFlagsExt",
|
||||||
|
"burstId",
|
||||||
|
"captionEnc",
|
||||||
|
"locationEnc",
|
||||||
|
"locationV2Enc",
|
||||||
|
"locationLatitude",
|
||||||
|
"locationLongitude",
|
||||||
|
"adjustmentType",
|
||||||
|
"timeZoneOffset",
|
||||||
|
"vidComplDurValue",
|
||||||
|
"vidComplDurScale",
|
||||||
|
"vidComplDispValue",
|
||||||
|
"vidComplDispScale",
|
||||||
|
"vidComplVisibilityState",
|
||||||
|
"customRenderedValue",
|
||||||
|
"containerId",
|
||||||
|
"itemId",
|
||||||
|
"position",
|
||||||
|
"isKeyAsset",
|
||||||
],
|
],
|
||||||
u'zoneID': {u'zoneName': u'PrimarySync'}
|
"zoneID": {"zoneName": "PrimarySync"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if query_filter:
|
if query_filter:
|
||||||
query['query']['filterBy'].extend(query_filter)
|
query["query"]["filterBy"].extend(query_filter)
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def __unicode__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
as_unicode = self.__unicode__()
|
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
return as_unicode
|
|
||||||
else:
|
|
||||||
return as_unicode.encode('utf-8', 'ignore')
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s: '%s'>" % (
|
return f"<{type(self).__name__}: '{self}'>"
|
||||||
type(self).__name__,
|
|
||||||
self
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PhotoAsset(object):
|
class PhotoAsset:
|
||||||
|
"""A photo."""
|
||||||
|
|
||||||
def __init__(self, service, master_record, asset_record):
|
def __init__(self, service, master_record, asset_record):
|
||||||
self._service = service
|
self._service = service
|
||||||
self._master_record = master_record
|
self._master_record = master_record
|
||||||
|
@ -422,91 +488,151 @@ class PhotoAsset(object):
|
||||||
self._versions = None
|
self._versions = None
|
||||||
|
|
||||||
PHOTO_VERSION_LOOKUP = {
|
PHOTO_VERSION_LOOKUP = {
|
||||||
u"original": u"resOriginal",
|
"original": "resOriginal",
|
||||||
u"medium": u"resJPEGMed",
|
"medium": "resJPEGMed",
|
||||||
u"thumb": u"resJPEGThumb"
|
"thumb": "resJPEGThumb",
|
||||||
}
|
}
|
||||||
|
|
||||||
VIDEO_VERSION_LOOKUP = {
|
VIDEO_VERSION_LOOKUP = {
|
||||||
u"original": u"resOriginal",
|
"original": "resOriginal",
|
||||||
u"medium": u"resVidMed",
|
"medium": "resVidMed",
|
||||||
u"thumb": u"resVidSmall"
|
"thumb": "resVidSmall",
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def id(self):
|
def id(self):
|
||||||
return self._master_record['recordName']
|
"""Gets the photo id."""
|
||||||
|
return self._master_record["recordName"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
|
"""Gets the photo file name."""
|
||||||
return base64.b64decode(
|
return base64.b64decode(
|
||||||
self._master_record['fields']['filenameEnc']['value']
|
self._master_record["fields"]["filenameEnc"]["value"]
|
||||||
).decode('utf-8')
|
).decode("utf-8")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self):
|
def size(self):
|
||||||
return self._master_record['fields']['resOriginalRes']['value']['size']
|
"""Gets the photo size."""
|
||||||
|
return self._master_record["fields"]["resOriginalRes"]["value"]["size"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created(self):
|
def created(self):
|
||||||
|
"""Gets the photo created date."""
|
||||||
return self.asset_date
|
return self.asset_date
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def asset_date(self):
|
def asset_date(self):
|
||||||
|
"""Gets the photo asset date."""
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(
|
return datetime.utcfromtimestamp(
|
||||||
self._asset_record['fields']['assetDate']['value'] / 1000.0,
|
self._asset_record["fields"]["assetDate"]["value"] / 1000.0
|
||||||
tz=pytz.utc)
|
).replace(tzinfo=timezone.utc)
|
||||||
except:
|
except KeyError:
|
||||||
dt = datetime.fromtimestamp(0)
|
return datetime.utcfromtimestamp(0).replace(tzinfo=timezone.utc)
|
||||||
return dt
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def added_date(self):
|
def added_date(self):
|
||||||
dt = datetime.fromtimestamp(
|
"""Gets the photo added date."""
|
||||||
self._asset_record['fields']['addedDate']['value'] / 1000.0,
|
return datetime.utcfromtimestamp(
|
||||||
tz=pytz.utc)
|
self._asset_record["fields"]["addedDate"]["value"] / 1000.0
|
||||||
return dt
|
).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dimensions(self):
|
def dimensions(self):
|
||||||
return (self._master_record['fields']['resOriginalWidth']['value'],
|
"""Gets the photo dimensions."""
|
||||||
self._master_record['fields']['resOriginalHeight']['value'])
|
return (
|
||||||
|
self._master_record["fields"]["resOriginalWidth"]["value"],
|
||||||
|
self._master_record["fields"]["resOriginalHeight"]["value"],
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def versions(self):
|
def versions(self):
|
||||||
|
"""Gets the photo versions."""
|
||||||
if not self._versions:
|
if not self._versions:
|
||||||
self._versions = {}
|
self._versions = {}
|
||||||
if 'resVidSmallRes' in self._master_record['fields']:
|
if "resVidSmallRes" in self._master_record["fields"]:
|
||||||
typed_version_lookup = self.VIDEO_VERSION_LOOKUP
|
typed_version_lookup = self.VIDEO_VERSION_LOOKUP
|
||||||
else:
|
else:
|
||||||
typed_version_lookup = self.PHOTO_VERSION_LOOKUP
|
typed_version_lookup = self.PHOTO_VERSION_LOOKUP
|
||||||
|
|
||||||
for key, prefix in typed_version_lookup.items():
|
for key, prefix in typed_version_lookup.items():
|
||||||
if '%sWidth' % prefix in self._master_record['fields']:
|
if "%sRes" % prefix in self._master_record["fields"]:
|
||||||
f = self._master_record['fields']
|
fields = self._master_record["fields"]
|
||||||
self._versions[key] = {
|
version = {"filename": self.filename}
|
||||||
'width': f['%sWidth' % prefix]['value'],
|
|
||||||
'height': f['%sHeight' % prefix]['value'],
|
width_entry = fields.get("%sWidth" % prefix)
|
||||||
'size': f['%sRes' % prefix]['value']['size'],
|
if width_entry:
|
||||||
'type': f['%sFileType' % prefix]['value'],
|
version["width"] = width_entry["value"]
|
||||||
'url': f['%sRes' % prefix]['value']['downloadURL'],
|
else:
|
||||||
'filename': self.filename,
|
version["width"] = None
|
||||||
}
|
|
||||||
|
height_entry = fields.get("%sHeight" % prefix)
|
||||||
|
if height_entry:
|
||||||
|
version["height"] = height_entry["value"]
|
||||||
|
else:
|
||||||
|
version["height"] = None
|
||||||
|
|
||||||
|
size_entry = fields.get("%sRes" % prefix)
|
||||||
|
if size_entry:
|
||||||
|
version["size"] = size_entry["value"]["size"]
|
||||||
|
version["url"] = size_entry["value"]["downloadURL"]
|
||||||
|
else:
|
||||||
|
version["size"] = None
|
||||||
|
version["url"] = None
|
||||||
|
|
||||||
|
type_entry = fields.get("%sFileType" % prefix)
|
||||||
|
if type_entry:
|
||||||
|
version["type"] = type_entry["value"]
|
||||||
|
else:
|
||||||
|
version["type"] = None
|
||||||
|
|
||||||
|
self._versions[key] = version
|
||||||
|
|
||||||
return self._versions
|
return self._versions
|
||||||
|
|
||||||
def download(self, version='original', **kwargs):
|
def download(self, version="original", **kwargs):
|
||||||
|
"""Returns the photo file."""
|
||||||
if version not in self.versions:
|
if version not in self.versions:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._service.session.get(
|
return self._service.session.get(
|
||||||
self.versions[version]['url'],
|
self.versions[version]["url"], stream=True, **kwargs
|
||||||
stream=True,
|
)
|
||||||
**kwargs
|
|
||||||
|
def delete(self):
|
||||||
|
"""Deletes the photo."""
|
||||||
|
json_data = (
|
||||||
|
'{"query":{"recordType":"CheckIndexingState"},'
|
||||||
|
'"zoneID":{"zoneName":"PrimarySync"}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
json_data = (
|
||||||
|
'{"operations":[{'
|
||||||
|
'"operationType":"update",'
|
||||||
|
'"record":{'
|
||||||
|
'"recordName":"%s",'
|
||||||
|
'"recordType":"%s",'
|
||||||
|
'"recordChangeTag":"%s",'
|
||||||
|
'"fields":{"isDeleted":{"value":1}'
|
||||||
|
"}}}],"
|
||||||
|
'"zoneID":{'
|
||||||
|
'"zoneName":"PrimarySync"'
|
||||||
|
'},"atomic":true}'
|
||||||
|
% (
|
||||||
|
self._asset_record["recordName"],
|
||||||
|
self._asset_record["recordType"],
|
||||||
|
self._master_record["recordChangeTag"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint = self._service.service_endpoint
|
||||||
|
params = urlencode(self._service.params)
|
||||||
|
url = f"{endpoint}/records/modify?{params}"
|
||||||
|
|
||||||
|
return self._service.session.post(
|
||||||
|
url, data=json_data, headers={"Content-type": "text/plain"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s: id=%s>" % (
|
return f"<{type(self).__name__}: id={self.id}>"
|
||||||
type(self).__name__,
|
|
||||||
self.id
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,121 +1,123 @@
|
||||||
from __future__ import absolute_import
|
"""Reminders service."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone_name
|
||||||
|
|
||||||
|
|
||||||
class RemindersService(object):
|
class RemindersService:
|
||||||
|
"""The 'Reminders' iCloud service."""
|
||||||
|
|
||||||
def __init__(self, service_root, session, params):
|
def __init__(self, service_root, session, params):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = params
|
self._params = params
|
||||||
self._service_root = service_root
|
self._service_root = service_root
|
||||||
|
|
||||||
self.lists = {}
|
self.lists = {}
|
||||||
self.collections = {}
|
self.collections = {}
|
||||||
|
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
params_reminders = dict(self.params)
|
"""Refresh data."""
|
||||||
params_reminders.update({
|
params_reminders = dict(self._params)
|
||||||
'clientVersion': '4.0',
|
params_reminders.update(
|
||||||
'lang': 'en-us',
|
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
|
||||||
'usertz': get_localzone().zone
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# Open reminders
|
# Open reminders
|
||||||
req = self.session.get(
|
req = self.session.get(
|
||||||
self._service_root + '/rd/startup',
|
self._service_root + "/rd/startup", params=params_reminders
|
||||||
params=params_reminders
|
|
||||||
)
|
)
|
||||||
|
|
||||||
startup = req.json()
|
data = req.json()
|
||||||
|
|
||||||
self.lists = {}
|
self.lists = {}
|
||||||
self.collections = {}
|
self.collections = {}
|
||||||
for collection in startup['Collections']:
|
for collection in data["Collections"]:
|
||||||
temp = []
|
temp = []
|
||||||
self.collections[collection['title']] = {
|
self.collections[collection["title"]] = {
|
||||||
'guid': collection['guid'],
|
"guid": collection["guid"],
|
||||||
'ctag': collection['ctag']
|
"ctag": collection["ctag"],
|
||||||
}
|
}
|
||||||
for reminder in startup['Reminders']:
|
for reminder in data["Reminders"]:
|
||||||
|
|
||||||
if reminder['pGuid'] != collection['guid']:
|
if reminder["pGuid"] != collection["guid"]:
|
||||||
continue
|
continue
|
||||||
if 'dueDate' in reminder:
|
|
||||||
if reminder['dueDate']:
|
if reminder.get("dueDate"):
|
||||||
due = datetime(
|
due = datetime(
|
||||||
reminder['dueDate'][1],
|
reminder["dueDate"][1],
|
||||||
reminder['dueDate'][2], reminder['dueDate'][3],
|
reminder["dueDate"][2],
|
||||||
reminder['dueDate'][4], reminder['dueDate'][5]
|
reminder["dueDate"][3],
|
||||||
)
|
reminder["dueDate"][4],
|
||||||
else:
|
reminder["dueDate"][5],
|
||||||
due = None
|
)
|
||||||
else:
|
else:
|
||||||
due = None
|
due = None
|
||||||
if reminder['description']:
|
|
||||||
desc = reminder['description']
|
|
||||||
else:
|
|
||||||
desc = ""
|
|
||||||
temp.append({
|
|
||||||
"title": reminder['title'],
|
|
||||||
"desc": desc,
|
|
||||||
"due": due
|
|
||||||
})
|
|
||||||
self.lists[collection['title']] = temp
|
|
||||||
|
|
||||||
def post(self, title, description="", collection=None, dueDate=None):
|
temp.append(
|
||||||
pguid = 'tasks'
|
{
|
||||||
|
"title": reminder["title"],
|
||||||
|
"desc": reminder.get("description"),
|
||||||
|
"due": due,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.lists[collection["title"]] = temp
|
||||||
|
|
||||||
|
def post(self, title, description="", collection=None, due_date=None):
|
||||||
|
"""Adds a new reminder."""
|
||||||
|
pguid = "tasks"
|
||||||
if collection:
|
if collection:
|
||||||
if collection in self.collections:
|
if collection in self.collections:
|
||||||
pguid = self.collections[collection]['guid']
|
pguid = self.collections[collection]["guid"]
|
||||||
|
|
||||||
params_reminders = dict(self.params)
|
params_reminders = dict(self._params)
|
||||||
params_reminders.update({
|
params_reminders.update(
|
||||||
'clientVersion': '4.0',
|
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
|
||||||
'lang': 'en-us',
|
)
|
||||||
'usertz': get_localzone().zone
|
|
||||||
})
|
|
||||||
|
|
||||||
dueDateList = None
|
due_dates = None
|
||||||
if dueDate:
|
if due_date:
|
||||||
dueDateList = [
|
due_dates = [
|
||||||
int(str(dueDate.year) + str(dueDate.month) + str(dueDate.day)),
|
int(str(due_date.year) + str(due_date.month) + str(due_date.day)),
|
||||||
dueDate.year,
|
due_date.year,
|
||||||
dueDate.month,
|
due_date.month,
|
||||||
dueDate.day,
|
due_date.day,
|
||||||
dueDate.hour,
|
due_date.hour,
|
||||||
dueDate.minute
|
due_date.minute,
|
||||||
]
|
]
|
||||||
|
|
||||||
req = self.session.post(
|
req = self.session.post(
|
||||||
self._service_root + '/rd/reminders/tasks',
|
self._service_root + "/rd/reminders/tasks",
|
||||||
data=json.dumps({
|
data=json.dumps(
|
||||||
"Reminders": {
|
{
|
||||||
'title': title,
|
"Reminders": {
|
||||||
"description": description,
|
"title": title,
|
||||||
"pGuid": pguid,
|
"description": description,
|
||||||
"etag": None,
|
"pGuid": pguid,
|
||||||
"order": None,
|
"etag": None,
|
||||||
"priority": 0,
|
"order": None,
|
||||||
"recurrence": None,
|
"priority": 0,
|
||||||
"alarms": [],
|
"recurrence": None,
|
||||||
"startDate": None,
|
"alarms": [],
|
||||||
"startDateTz": None,
|
"startDate": None,
|
||||||
"startDateIsAllDay": False,
|
"startDateTz": None,
|
||||||
"completedDate": None,
|
"startDateIsAllDay": False,
|
||||||
"dueDate": dueDateList,
|
"completedDate": None,
|
||||||
"dueDateIsAllDay": False,
|
"dueDate": due_dates,
|
||||||
"lastModifiedDate": None,
|
"dueDateIsAllDay": False,
|
||||||
"createdDate": None,
|
"lastModifiedDate": None,
|
||||||
"isFamily": None,
|
"createdDate": None,
|
||||||
"createdDateExtended": int(time.time()*1000),
|
"isFamily": None,
|
||||||
"guid": str(uuid.uuid4())
|
"createdDateExtended": int(time.time() * 1000),
|
||||||
},
|
"guid": str(uuid.uuid4()),
|
||||||
"ClientState": {"Collections": list(self.collections.values())}
|
},
|
||||||
}),
|
"ClientState": {"Collections": list(self.collections.values())},
|
||||||
params=params_reminders)
|
}
|
||||||
|
),
|
||||||
|
params=params_reminders,
|
||||||
|
)
|
||||||
return req.ok
|
return req.ok
|
||||||
|
|
|
@ -1,49 +1,43 @@
|
||||||
|
"""File service."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
class UbiquityService(object):
|
class UbiquityService:
|
||||||
"""The 'Ubiquity' iCloud service."""
|
"""The 'Ubiquity' iCloud service."""
|
||||||
|
|
||||||
def __init__(self, service_root, session, params):
|
def __init__(self, service_root, session, params):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
self._root = None
|
self._root = None
|
||||||
|
self._node_url = service_root + "/ws/%s/%s/%s"
|
||||||
self._service_root = service_root
|
|
||||||
self._node_url = '/ws/%s/%s/%s'
|
|
||||||
|
|
||||||
def get_node_url(self, id, variant='item'):
|
|
||||||
return self._service_root + self._node_url % (
|
|
||||||
self.params['dsid'],
|
|
||||||
variant,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_node(self, id):
|
|
||||||
request = self.session.get(self.get_node_url(id))
|
|
||||||
return UbiquityNode(self, request.json())
|
|
||||||
|
|
||||||
def get_children(self, id):
|
|
||||||
request = self.session.get(
|
|
||||||
self.get_node_url(id, 'parent')
|
|
||||||
)
|
|
||||||
items = request.json()['item_list']
|
|
||||||
return [UbiquityNode(self, item) for item in items]
|
|
||||||
|
|
||||||
def get_file(self, id, **kwargs):
|
|
||||||
request = self.session.get(
|
|
||||||
self.get_node_url(id, 'file'),
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
return request
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def root(self):
|
def root(self):
|
||||||
|
"""Gets the root node."""
|
||||||
if not self._root:
|
if not self._root:
|
||||||
self._root = self.get_node(0)
|
self._root = self.get_node(0)
|
||||||
return self._root
|
return self._root
|
||||||
|
|
||||||
|
def get_node_url(self, node_id, variant="item"):
|
||||||
|
"""Returns a node URL."""
|
||||||
|
return self._node_url % (self.params["dsid"], variant, node_id)
|
||||||
|
|
||||||
|
def get_node(self, node_id):
|
||||||
|
"""Returns a node."""
|
||||||
|
request = self.session.get(self.get_node_url(node_id))
|
||||||
|
return UbiquityNode(self, request.json())
|
||||||
|
|
||||||
|
def get_children(self, node_id):
|
||||||
|
"""Returns a node children."""
|
||||||
|
request = self.session.get(self.get_node_url(node_id, "parent"))
|
||||||
|
items = request.json()["item_list"]
|
||||||
|
return [UbiquityNode(self, item) for item in items]
|
||||||
|
|
||||||
|
def get_file(self, node_id, **kwargs):
|
||||||
|
"""Returns a node file."""
|
||||||
|
return self.session.get(self.get_node_url(node_id, "file"), **kwargs)
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
return getattr(self.root, attr)
|
return getattr(self.root, attr)
|
||||||
|
|
||||||
|
@ -51,71 +45,69 @@ class UbiquityService(object):
|
||||||
return self.root[key]
|
return self.root[key]
|
||||||
|
|
||||||
|
|
||||||
class UbiquityNode(object):
|
class UbiquityNode:
|
||||||
|
"""Ubiquity node."""
|
||||||
|
|
||||||
def __init__(self, conn, data):
|
def __init__(self, conn, data):
|
||||||
self.data = data
|
self.data = data
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
|
|
||||||
|
self._children = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def item_id(self):
|
def item_id(self):
|
||||||
return self.data.get('item_id')
|
"""Gets the node id."""
|
||||||
|
return self.data.get("item_id")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.data.get('name')
|
"""Gets the node name."""
|
||||||
|
return self.data.get("name")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self):
|
||||||
return self.data.get('type')
|
"""Gets the node type."""
|
||||||
|
return self.data.get("type")
|
||||||
def get_children(self):
|
|
||||||
if not hasattr(self, '_children'):
|
|
||||||
self._children = self.connection.get_children(self.item_id)
|
|
||||||
return self._children
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self):
|
def size(self):
|
||||||
|
"""Gets the node size."""
|
||||||
try:
|
try:
|
||||||
return int(self.data.get('size'))
|
return int(self.data.get("size"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modified(self):
|
def modified(self):
|
||||||
return datetime.strptime(
|
"""Gets the node modified date."""
|
||||||
self.data.get('modified'),
|
return datetime.strptime(self.data.get("modified"), "%Y-%m-%dT%H:%M:%SZ")
|
||||||
'%Y-%m-%dT%H:%M:%SZ'
|
|
||||||
)
|
|
||||||
|
|
||||||
def dir(self):
|
|
||||||
return [child.name for child in self.get_children()]
|
|
||||||
|
|
||||||
def open(self, **kwargs):
|
def open(self, **kwargs):
|
||||||
|
"""Returns the node file."""
|
||||||
return self.connection.get_file(self.item_id, **kwargs)
|
return self.connection.get_file(self.item_id, **kwargs)
|
||||||
|
|
||||||
|
def get_children(self):
|
||||||
|
"""Returns the node children."""
|
||||||
|
if not self._children:
|
||||||
|
self._children = self.connection.get_children(self.item_id)
|
||||||
|
return self._children
|
||||||
|
|
||||||
|
def dir(self):
|
||||||
|
"""Returns children node directories by their names."""
|
||||||
|
return [child.name for child in self.get_children()]
|
||||||
|
|
||||||
def get(self, name):
|
def get(self, name):
|
||||||
return [
|
"""Returns a child node by its name."""
|
||||||
child for child in self.get_children() if child.name == name
|
return [child for child in self.get_children() if child.name == name][0]
|
||||||
][0]
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
try:
|
try:
|
||||||
return self.get(key)
|
return self.get(key)
|
||||||
except IndexError:
|
except IndexError as i:
|
||||||
raise KeyError('No child named %s exists' % key)
|
raise KeyError(f"No child named {key} exists") from i
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
as_unicode = self.__unicode__()
|
return self.name
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
return as_unicode
|
|
||||||
else:
|
|
||||||
return as_unicode.encode('utf-8', 'ignore')
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s: '%s'>" % (
|
return f"<{self.type.capitalize()}: '{self}'>"
|
||||||
self.type.capitalize(),
|
|
||||||
self
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
"""Utils."""
|
||||||
import getpass
|
import getpass
|
||||||
import keyring
|
import keyring
|
||||||
import sys
|
import sys
|
||||||
|
@ -5,10 +6,11 @@ import sys
|
||||||
from .exceptions import PyiCloudNoStoredPasswordAvailableException
|
from .exceptions import PyiCloudNoStoredPasswordAvailableException
|
||||||
|
|
||||||
|
|
||||||
KEYRING_SYSTEM = 'pyicloud://icloud-password'
|
KEYRING_SYSTEM = "pyicloud://icloud-password"
|
||||||
|
|
||||||
|
|
||||||
def get_password(username, interactive=sys.stdout.isatty()):
|
def get_password(username, interactive=sys.stdout.isatty()):
|
||||||
|
"""Get the password from a username."""
|
||||||
try:
|
try:
|
||||||
return get_password_from_keyring(username)
|
return get_password_from_keyring(username)
|
||||||
except PyiCloudNoStoredPasswordAvailableException:
|
except PyiCloudNoStoredPasswordAvailableException:
|
||||||
|
@ -16,13 +18,14 @@ def get_password(username, interactive=sys.stdout.isatty()):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return getpass.getpass(
|
return getpass.getpass(
|
||||||
'Enter iCloud password for {username}: '.format(
|
"Enter iCloud password for {username}: ".format(
|
||||||
username=username,
|
username=username,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def password_exists_in_keyring(username):
|
def password_exists_in_keyring(username):
|
||||||
|
"""Return true if the password of a username exists in the keyring."""
|
||||||
try:
|
try:
|
||||||
get_password_from_keyring(username)
|
get_password_from_keyring(username)
|
||||||
except PyiCloudNoStoredPasswordAvailableException:
|
except PyiCloudNoStoredPasswordAvailableException:
|
||||||
|
@ -32,10 +35,8 @@ def password_exists_in_keyring(username):
|
||||||
|
|
||||||
|
|
||||||
def get_password_from_keyring(username):
|
def get_password_from_keyring(username):
|
||||||
result = keyring.get_password(
|
"""Get the password from a username."""
|
||||||
KEYRING_SYSTEM,
|
result = keyring.get_password(KEYRING_SYSTEM, username)
|
||||||
username
|
|
||||||
)
|
|
||||||
if result is None:
|
if result is None:
|
||||||
raise PyiCloudNoStoredPasswordAvailableException(
|
raise PyiCloudNoStoredPasswordAvailableException(
|
||||||
"No pyicloud password for {username} could be found "
|
"No pyicloud password for {username} could be found "
|
||||||
|
@ -50,6 +51,7 @@ def get_password_from_keyring(username):
|
||||||
|
|
||||||
|
|
||||||
def store_password_in_keyring(username, password):
|
def store_password_in_keyring(username, password):
|
||||||
|
"""Store the password of a username."""
|
||||||
return keyring.set_password(
|
return keyring.set_password(
|
||||||
KEYRING_SYSTEM,
|
KEYRING_SYSTEM,
|
||||||
username,
|
username,
|
||||||
|
@ -58,6 +60,7 @@ def store_password_in_keyring(username, password):
|
||||||
|
|
||||||
|
|
||||||
def delete_password_in_keyring(username):
|
def delete_password_in_keyring(username):
|
||||||
|
"""Delete the password of a username."""
|
||||||
return keyring.delete_password(
|
return keyring.delete_password(
|
||||||
KEYRING_SYSTEM,
|
KEYRING_SYSTEM,
|
||||||
username,
|
username,
|
||||||
|
@ -65,8 +68,9 @@ def delete_password_in_keyring(username):
|
||||||
|
|
||||||
|
|
||||||
def underscore_to_camelcase(word, initial_capital=False):
|
def underscore_to_camelcase(word, initial_capital=False):
|
||||||
words = [x.capitalize() or '_' for x in word.split('_')]
|
"""Transform a word to camelCase."""
|
||||||
|
words = [x.capitalize() or "_" for x in word.split("_")]
|
||||||
if not initial_capital:
|
if not initial_capital:
|
||||||
words[0] = words[0].lower()
|
words[0] = words[0].lower()
|
||||||
|
|
||||||
return ''.join(words)
|
return "".join(words)
|
||||||
|
|
46
pylintrc
Normal file
46
pylintrc
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
[MASTER]
|
||||||
|
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||||
|
# any too bad. Override on command line as appropriate.
|
||||||
|
jobs=2
|
||||||
|
persistent=no
|
||||||
|
extension-pkg-whitelist=ciso8601
|
||||||
|
|
||||||
|
[BASIC]
|
||||||
|
good-names=id,i,j,k
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
# Reasons disabled:
|
||||||
|
# format - handled by black
|
||||||
|
# duplicate-code - unavoidable
|
||||||
|
# too-many-* - are not enforced for the sake of readability
|
||||||
|
# too-few-* - same as too-many-*
|
||||||
|
# inconsistent-return-statements - doesn't handle raise
|
||||||
|
# unnecessary-pass - readability for functions which only contain pass
|
||||||
|
# useless-object-inheritance - should be removed while droping Python 2
|
||||||
|
# wrong-import-order - isort guards this
|
||||||
|
# consider-using-f-string - temporarily to be able to not block Python upgrade
|
||||||
|
disable=
|
||||||
|
format,
|
||||||
|
duplicate-code,
|
||||||
|
inconsistent-return-statements,
|
||||||
|
too-few-public-methods,
|
||||||
|
too-many-ancestors,
|
||||||
|
too-many-arguments,
|
||||||
|
too-many-branches,
|
||||||
|
too-many-instance-attributes,
|
||||||
|
too-many-lines,
|
||||||
|
too-many-locals,
|
||||||
|
too-many-public-methods,
|
||||||
|
too-many-return-statements,
|
||||||
|
too-many-statements,
|
||||||
|
too-many-boolean-expressions,
|
||||||
|
unnecessary-pass,
|
||||||
|
useless-object-inheritance,
|
||||||
|
wrong-import-order,
|
||||||
|
consider-using-f-string
|
||||||
|
|
||||||
|
[FORMAT]
|
||||||
|
expected-line-ending-format=LF
|
||||||
|
|
||||||
|
[EXCEPTIONS]
|
||||||
|
overgeneral-exceptions=PyiCloudException
|
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ["py37", "py38", "py39", "py310"]
|
||||||
|
exclude = '''
|
||||||
|
|
||||||
|
(
|
||||||
|
/(
|
||||||
|
\.eggs
|
||||||
|
| \.git
|
||||||
|
| \.hg
|
||||||
|
| \.mypy_cache
|
||||||
|
| \.tox
|
||||||
|
| \.venv
|
||||||
|
| _build
|
||||||
|
| buck-out
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
)/
|
||||||
|
| exceptions.py
|
||||||
|
)
|
||||||
|
'''
|
|
@ -1,9 +1,6 @@
|
||||||
requests>=1.2
|
requests>=2.24.0
|
||||||
keyring>=8.0,<9.0
|
keyring>=21.4.0
|
||||||
keyrings.alt>=1.0,<2.0
|
keyrings.alt>=3.5.2
|
||||||
click>=6.0,<7.0
|
click>=7.1.2
|
||||||
six>=1.9.0
|
tzlocal>=4.0
|
||||||
tzlocal
|
certifi>=2020.6.20
|
||||||
pytz
|
|
||||||
certifi
|
|
||||||
future
|
|
||||||
|
|
3
requirements_all.txt
Normal file
3
requirements_all.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
-r requirements.txt
|
||||||
|
-r requirements_test.txt
|
||||||
|
tox==3.24.5
|
4
requirements_test.txt
Normal file
4
requirements_test.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
black==22.1.0
|
||||||
|
pylint==2.12.2
|
||||||
|
pylint-strict-informational==0.1
|
||||||
|
pytest==7.0.1
|
7
scripts/clean.sh
Executable file
7
scripts/clean.sh
Executable file
|
@ -0,0 +1,7 @@
|
||||||
|
./scripts/common.sh
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
rm -r .tox
|
||||||
|
rm -r build
|
||||||
|
rm -r dist
|
||||||
|
rm -r pyicloud.egg-info
|
4
scripts/common.sh
Executable file
4
scripts/common.sh
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
# Be in right place
|
||||||
|
if [ ! -f setup.py ]; then
|
||||||
|
cd ..
|
||||||
|
fi
|
18
scripts/publish.sh
Executable file
18
scripts/publish.sh
Executable file
|
@ -0,0 +1,18 @@
|
||||||
|
# Publish the library
|
||||||
|
# https://pypi.org/project/pyicloud/
|
||||||
|
# Publish documentation here: https://packaging.python.org/tutorials/packaging-projects/
|
||||||
|
|
||||||
|
./scripts/common.sh
|
||||||
|
./scripts/clean.sh
|
||||||
|
|
||||||
|
# Install/update dependencies
|
||||||
|
python3 -m pip install --user --upgrade setuptools wheel
|
||||||
|
python3 -m pip install --user --upgrade twine
|
||||||
|
|
||||||
|
# Build
|
||||||
|
python3 setup.py sdist bdist_wheel
|
||||||
|
|
||||||
|
# Push to PyPi
|
||||||
|
python3 -m twine upload dist/*
|
||||||
|
|
||||||
|
# Enter credentials manually :P
|
|
@ -1,3 +1,3 @@
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
norecursedirs=lib build .tox
|
norecursedirs=.git .tox build lib
|
||||||
|
|
56
setup.py
56
setup.py
|
@ -1,35 +1,43 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""pyiCloud setup."""
|
||||||
|
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
REPO_URL = "https://github.com/picklepete/pyicloud"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
|
||||||
with open('requirements.txt') as f:
|
with open("requirements.txt") as f:
|
||||||
required = f.read().splitlines()
|
required = f.read().splitlines()
|
||||||
|
|
||||||
|
with open("README.rst", encoding="utf-8") as f:
|
||||||
|
long_description = f.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='pyicloud',
|
name="pyicloud",
|
||||||
version='0.9.5',
|
version=VERSION,
|
||||||
url='https://github.com/picklepete/pyicloud',
|
url=REPO_URL,
|
||||||
description=(
|
download_url=REPO_URL + "/tarball/" + VERSION,
|
||||||
'PyiCloud is a module which allows pythonistas to '
|
description="PyiCloud is a module which allows pythonistas to interact with iCloud webservices.",
|
||||||
'interact with iCloud webservices.'
|
long_description=long_description,
|
||||||
),
|
maintainer="The PyiCloud Authors",
|
||||||
maintainer='The PyiCloud Authors',
|
packages=find_packages(include=["pyicloud*"]),
|
||||||
maintainer_email=' ',
|
|
||||||
license='MIT',
|
|
||||||
packages=find_packages(),
|
|
||||||
install_requires=required,
|
install_requires=required,
|
||||||
|
python_requires=">=3.7",
|
||||||
|
license="MIT",
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Intended Audience :: Developers',
|
"Development Status :: 5 - Production/Stable",
|
||||||
'Operating System :: OS Independent',
|
"Intended Audience :: Developers",
|
||||||
'Programming Language :: Python',
|
"License :: OSI Approved :: MIT License",
|
||||||
'Programming Language :: Python :: 2.6',
|
"Operating System :: OS Independent",
|
||||||
'Programming Language :: Python :: 2.7',
|
"Programming Language :: Python",
|
||||||
'Programming Language :: Python :: 3',
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
'License :: OSI Approved :: MIT License',
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.7",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Topic :: Software Development :: Libraries",
|
||||||
],
|
],
|
||||||
entry_points={
|
entry_points={"console_scripts": ["icloud = pyicloud.cmdline:main"]},
|
||||||
'console_scripts': [
|
keywords=["icloud", "find-my-iphone"],
|
||||||
'icloud = pyicloud.cmdline:main'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Library tests."""
|
||||||
|
import json
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
from pyicloud import base
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
AUTHENTICATED_USER,
|
||||||
|
REQUIRES_2FA_USER,
|
||||||
|
REQUIRES_2FA_TOKEN,
|
||||||
|
VALID_TOKEN,
|
||||||
|
VALID_USERS,
|
||||||
|
VALID_PASSWORD,
|
||||||
|
VALID_COOKIE,
|
||||||
|
VALID_2FA_CODE,
|
||||||
|
VALID_TOKENS,
|
||||||
|
)
|
||||||
|
from .const_login import (
|
||||||
|
AUTH_OK,
|
||||||
|
LOGIN_WORKING,
|
||||||
|
LOGIN_2FA,
|
||||||
|
TRUSTED_DEVICES,
|
||||||
|
TRUSTED_DEVICE_1,
|
||||||
|
VERIFICATION_CODE_OK,
|
||||||
|
VERIFICATION_CODE_KO,
|
||||||
|
)
|
||||||
|
from .const_account import ACCOUNT_DEVICES_WORKING, ACCOUNT_STORAGE_WORKING
|
||||||
|
from .const_account_family import ACCOUNT_FAMILY_WORKING
|
||||||
|
from .const_drive import (
|
||||||
|
DRIVE_FOLDER_WORKING,
|
||||||
|
DRIVE_ROOT_INVALID,
|
||||||
|
DRIVE_SUBFOLDER_WORKING,
|
||||||
|
DRIVE_ROOT_WORKING,
|
||||||
|
DRIVE_FILE_DOWNLOAD_WORKING,
|
||||||
|
)
|
||||||
|
from .const_findmyiphone import FMI_FAMILY_WORKING
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseMock(Response):
|
||||||
|
"""Mocked Response."""
|
||||||
|
|
||||||
|
def __init__(self, result, status_code=200, **kwargs):
|
||||||
|
"""Set up response mock."""
|
||||||
|
Response.__init__(self)
|
||||||
|
self.result = result
|
||||||
|
self.status_code = status_code
|
||||||
|
self.raw = kwargs.get("raw")
|
||||||
|
self.headers = kwargs.get("headers", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self):
|
||||||
|
"""Return text."""
|
||||||
|
return json.dumps(self.result)
|
||||||
|
|
||||||
|
|
||||||
|
class PyiCloudSessionMock(base.PyiCloudSession):
|
||||||
|
"""Mocked PyiCloudSession."""
|
||||||
|
|
||||||
|
def request(self, method, url, **kwargs):
|
||||||
|
"""Make the request."""
|
||||||
|
params = kwargs.get("params")
|
||||||
|
headers = kwargs.get("headers")
|
||||||
|
data = json.loads(kwargs.get("data", "{}"))
|
||||||
|
|
||||||
|
# Login
|
||||||
|
if self.service.SETUP_ENDPOINT in url:
|
||||||
|
if "accountLogin" in url and method == "POST":
|
||||||
|
if data.get("dsWebAuthToken") not in VALID_TOKENS:
|
||||||
|
self._raise_error(None, "Unknown reason")
|
||||||
|
if data.get("dsWebAuthToken") == REQUIRES_2FA_TOKEN:
|
||||||
|
return ResponseMock(LOGIN_2FA)
|
||||||
|
return ResponseMock(LOGIN_WORKING)
|
||||||
|
|
||||||
|
if "listDevices" in url and method == "GET":
|
||||||
|
return ResponseMock(TRUSTED_DEVICES)
|
||||||
|
|
||||||
|
if "sendVerificationCode" in url and method == "POST":
|
||||||
|
if data == TRUSTED_DEVICE_1:
|
||||||
|
return ResponseMock(VERIFICATION_CODE_OK)
|
||||||
|
return ResponseMock(VERIFICATION_CODE_KO)
|
||||||
|
|
||||||
|
if "validateVerificationCode" in url and method == "POST":
|
||||||
|
TRUSTED_DEVICE_1.update({"verificationCode": "0", "trustBrowser": True})
|
||||||
|
if data == TRUSTED_DEVICE_1:
|
||||||
|
self.service.user["apple_id"] = AUTHENTICATED_USER
|
||||||
|
return ResponseMock(VERIFICATION_CODE_OK)
|
||||||
|
self._raise_error(None, "FOUND_CODE")
|
||||||
|
|
||||||
|
if "validate" in url and method == "POST":
|
||||||
|
if headers.get("X-APPLE-WEBAUTH-TOKEN") == VALID_COOKIE:
|
||||||
|
return ResponseMock(LOGIN_WORKING)
|
||||||
|
self._raise_error(None, "Session expired")
|
||||||
|
|
||||||
|
if self.service.AUTH_ENDPOINT in url:
|
||||||
|
if "signin" in url and method == "POST":
|
||||||
|
if (
|
||||||
|
data.get("accountName") not in VALID_USERS
|
||||||
|
or data.get("password") != VALID_PASSWORD
|
||||||
|
):
|
||||||
|
self._raise_error(None, "Unknown reason")
|
||||||
|
if data.get("accountName") == REQUIRES_2FA_USER:
|
||||||
|
self.service.session_data["session_token"] = REQUIRES_2FA_TOKEN
|
||||||
|
return ResponseMock(AUTH_OK)
|
||||||
|
|
||||||
|
self.service.session_data["session_token"] = VALID_TOKEN
|
||||||
|
return ResponseMock(AUTH_OK)
|
||||||
|
|
||||||
|
if "securitycode" in url and method == "POST":
|
||||||
|
if data.get("securityCode", {}).get("code") != VALID_2FA_CODE:
|
||||||
|
self._raise_error(None, "Incorrect code")
|
||||||
|
|
||||||
|
self.service.session_data["session_token"] = VALID_TOKEN
|
||||||
|
return ResponseMock("", status_code=204)
|
||||||
|
|
||||||
|
if "trust" in url and method == "GET":
|
||||||
|
return ResponseMock("", status_code=204)
|
||||||
|
|
||||||
|
# Account
|
||||||
|
if "device/getDevices" in url and method == "GET":
|
||||||
|
return ResponseMock(ACCOUNT_DEVICES_WORKING)
|
||||||
|
if "family/getFamilyDetails" in url and method == "GET":
|
||||||
|
return ResponseMock(ACCOUNT_FAMILY_WORKING)
|
||||||
|
if "setup/ws/1/storageUsageInfo" in url and method == "GET":
|
||||||
|
return ResponseMock(ACCOUNT_STORAGE_WORKING)
|
||||||
|
|
||||||
|
# Drive
|
||||||
|
if (
|
||||||
|
"retrieveItemDetailsInFolders" in url
|
||||||
|
and method == "POST"
|
||||||
|
and data[0].get("drivewsid")
|
||||||
|
):
|
||||||
|
if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::root":
|
||||||
|
return ResponseMock(DRIVE_ROOT_WORKING)
|
||||||
|
if data[0].get("drivewsid") == "FOLDER::com.apple.CloudDocs::documents":
|
||||||
|
return ResponseMock(DRIVE_ROOT_INVALID)
|
||||||
|
if (
|
||||||
|
data[0].get("drivewsid")
|
||||||
|
== "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B"
|
||||||
|
):
|
||||||
|
return ResponseMock(DRIVE_FOLDER_WORKING)
|
||||||
|
if (
|
||||||
|
data[0].get("drivewsid")
|
||||||
|
== "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF"
|
||||||
|
):
|
||||||
|
return ResponseMock(DRIVE_SUBFOLDER_WORKING)
|
||||||
|
# Drive download
|
||||||
|
if "com.apple.CloudDocs/download/by_id" in url and method == "GET":
|
||||||
|
if params.get("document_id") == "516C896C-6AA5-4A30-B30E-5502C2333DAE":
|
||||||
|
return ResponseMock(DRIVE_FILE_DOWNLOAD_WORKING)
|
||||||
|
if "icloud-content.com" in url and method == "GET":
|
||||||
|
if "Scanned+document+1.pdf" in url:
|
||||||
|
return ResponseMock({}, raw=open(".gitignore", "rb"))
|
||||||
|
|
||||||
|
# Find My iPhone
|
||||||
|
if "fmi" in url and method == "POST":
|
||||||
|
return ResponseMock(FMI_FAMILY_WORKING)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PyiCloudServiceMock(base.PyiCloudService):
|
||||||
|
"""Mocked PyiCloudService."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
apple_id,
|
||||||
|
password=None,
|
||||||
|
cookie_directory=None,
|
||||||
|
verify=True,
|
||||||
|
client_id=None,
|
||||||
|
with_family=True,
|
||||||
|
):
|
||||||
|
"""Set up pyicloud service mock."""
|
||||||
|
base.PyiCloudSession = PyiCloudSessionMock
|
||||||
|
base.PyiCloudService.__init__(
|
||||||
|
self, apple_id, password, cookie_directory, verify, client_id, with_family
|
||||||
|
)
|
15
tests/const.py
Normal file
15
tests/const.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
"""Test constants."""
|
||||||
|
from .const_account_family import PRIMARY_EMAIL, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL
|
||||||
|
|
||||||
|
# Base
|
||||||
|
AUTHENTICATED_USER = PRIMARY_EMAIL
|
||||||
|
REQUIRES_2FA_TOKEN = "requires_2fa_token"
|
||||||
|
REQUIRES_2FA_USER = "requires_2fa_user"
|
||||||
|
VALID_USERS = [AUTHENTICATED_USER, REQUIRES_2FA_USER, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL]
|
||||||
|
VALID_PASSWORD = "valid_password"
|
||||||
|
VALID_COOKIE = "valid_cookie"
|
||||||
|
VALID_TOKEN = "valid_token"
|
||||||
|
VALID_2FA_CODE = "000000"
|
||||||
|
VALID_TOKENS = [VALID_TOKEN, REQUIRES_2FA_TOKEN]
|
||||||
|
|
||||||
|
CLIENT_ID = "client_id"
|
118
tests/const_account.py
Normal file
118
tests/const_account.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
"""Account test constants."""
|
||||||
|
from .const_login import FIRST_NAME
|
||||||
|
|
||||||
|
# Fakers
|
||||||
|
PAYMENT_METHOD_ID_1 = "PAYMENT_METHOD_ID_1"
|
||||||
|
PAYMENT_METHOD_ID_2 = "PAYMENT_METHOD_ID_2"
|
||||||
|
PAYMENT_METHOD_ID_3 = "PAYMENT_METHOD_ID_3"
|
||||||
|
PAYMENT_METHOD_ID_4 = "PAYMENT_METHOD_ID_4"
|
||||||
|
|
||||||
|
# Data
|
||||||
|
ACCOUNT_DEVICES_WORKING = {
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"serialNumber": "●●●●●●●NG123",
|
||||||
|
"osVersion": "OSX;10.15.3",
|
||||||
|
"modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox__2x.png",
|
||||||
|
"modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-infobox.png",
|
||||||
|
"paymentMethods": [PAYMENT_METHOD_ID_3],
|
||||||
|
"name": "MacBook Pro de " + FIRST_NAME,
|
||||||
|
"imei": "",
|
||||||
|
"model": "MacBookPro15,1",
|
||||||
|
"udid": "MacBookPro15,1" + FIRST_NAME,
|
||||||
|
"modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist__2x.png",
|
||||||
|
"modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/MacBookPro/MacBookPro15,1-spacegray/online-sourcelist.png",
|
||||||
|
"modelDisplayName": 'MacBook Pro 15"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serialNumber": "●●●●●●●UX123",
|
||||||
|
"osVersion": "iOS;13.3",
|
||||||
|
"modelLargePhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox__2x.png",
|
||||||
|
"modelLargePhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-infobox.png",
|
||||||
|
"paymentMethods": [
|
||||||
|
PAYMENT_METHOD_ID_4,
|
||||||
|
PAYMENT_METHOD_ID_2,
|
||||||
|
PAYMENT_METHOD_ID_1,
|
||||||
|
],
|
||||||
|
"name": "iPhone de " + FIRST_NAME,
|
||||||
|
"imei": "●●●●●●●●●●12345",
|
||||||
|
"model": "iPhone12,1",
|
||||||
|
"udid": "iPhone12,1" + FIRST_NAME,
|
||||||
|
"modelSmallPhotoURL2x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist__2x.png",
|
||||||
|
"modelSmallPhotoURL1x": "https://statici.icloud.com/fmipmobile/deviceImages-4.0/iPhone/iPhone12,1-1-6-0/online-sourcelist.png",
|
||||||
|
"modelDisplayName": "iPhone 11",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"paymentMethods": [
|
||||||
|
{
|
||||||
|
"lastFourDigits": "333",
|
||||||
|
"balanceStatus": "NOTAPPLICABLE",
|
||||||
|
"suspensionReason": "ACTIVE",
|
||||||
|
"id": PAYMENT_METHOD_ID_3,
|
||||||
|
"type": "Boursorama Banque",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lastFourDigits": "444",
|
||||||
|
"balanceStatus": "NOTAPPLICABLE",
|
||||||
|
"suspensionReason": "ACTIVE",
|
||||||
|
"id": PAYMENT_METHOD_ID_4,
|
||||||
|
"type": "Carte Crédit Agricole",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lastFourDigits": "2222",
|
||||||
|
"balanceStatus": "NOTAPPLICABLE",
|
||||||
|
"suspensionReason": "ACTIVE",
|
||||||
|
"id": PAYMENT_METHOD_ID_2,
|
||||||
|
"type": "Lydia",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lastFourDigits": "111",
|
||||||
|
"balanceStatus": "NOTAPPLICABLE",
|
||||||
|
"suspensionReason": "ACTIVE",
|
||||||
|
"id": PAYMENT_METHOD_ID_1,
|
||||||
|
"type": "Boursorama Banque",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_STORAGE_WORKING = {
|
||||||
|
"storageUsageByMedia": [
|
||||||
|
{
|
||||||
|
"mediaKey": "photos",
|
||||||
|
"displayLabel": "Photos et vidéos",
|
||||||
|
"displayColor": "ffcc00",
|
||||||
|
"usageInBytes": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaKey": "backup",
|
||||||
|
"displayLabel": "Sauvegarde",
|
||||||
|
"displayColor": "5856d6",
|
||||||
|
"usageInBytes": 799008186,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaKey": "docs",
|
||||||
|
"displayLabel": "Documents",
|
||||||
|
"displayColor": "ff9500",
|
||||||
|
"usageInBytes": 449092146,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaKey": "mail",
|
||||||
|
"displayLabel": "Mail",
|
||||||
|
"displayColor": "007aff",
|
||||||
|
"usageInBytes": 1101522944,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"storageUsageInfo": {
|
||||||
|
"compStorageInBytes": 0,
|
||||||
|
"usedStorageInBytes": 2348632876,
|
||||||
|
"totalStorageInBytes": 5368709120,
|
||||||
|
"commerceStorageInBytes": 0,
|
||||||
|
},
|
||||||
|
"quotaStatus": {
|
||||||
|
"overQuota": False,
|
||||||
|
"haveMaxQuotaTier": False,
|
||||||
|
"almost-full": False,
|
||||||
|
"paidQuota": False,
|
||||||
|
},
|
||||||
|
}
|
97
tests/const_account_family.py
Normal file
97
tests/const_account_family.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
"""Account family test constants."""
|
||||||
|
|
||||||
|
# Fakers
|
||||||
|
FIRST_NAME = "Quentin"
|
||||||
|
LAST_NAME = "TARANTINO"
|
||||||
|
FULL_NAME = FIRST_NAME + " " + LAST_NAME
|
||||||
|
PERSON_ID = (FIRST_NAME + LAST_NAME).lower()
|
||||||
|
PRIMARY_EMAIL = PERSON_ID + "@hotmail.fr"
|
||||||
|
APPLE_ID_EMAIL = PERSON_ID + "@me.com"
|
||||||
|
ICLOUD_ID_EMAIL = PERSON_ID + "@icloud.com"
|
||||||
|
|
||||||
|
MEMBER_1_FIRST_NAME = "John"
|
||||||
|
MEMBER_1_LAST_NAME = "TRAVOLTA"
|
||||||
|
MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME
|
||||||
|
MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower()
|
||||||
|
MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com"
|
||||||
|
|
||||||
|
MEMBER_2_FIRST_NAME = "Uma"
|
||||||
|
MEMBER_2_LAST_NAME = "THURMAN"
|
||||||
|
MEMBER_2_FULL_NAME = MEMBER_2_FIRST_NAME + " " + MEMBER_2_LAST_NAME
|
||||||
|
MEMBER_2_PERSON_ID = (MEMBER_2_FIRST_NAME + MEMBER_2_LAST_NAME).lower()
|
||||||
|
MEMBER_2_APPLE_ID = MEMBER_2_PERSON_ID + "@outlook.fr"
|
||||||
|
|
||||||
|
FAMILY_ID = "family_" + PERSON_ID
|
||||||
|
|
||||||
|
# Data
|
||||||
|
ACCOUNT_FAMILY_WORKING = {
|
||||||
|
"status-message": "Member of a family.",
|
||||||
|
"familyInvitations": [],
|
||||||
|
"outgoingTransferRequests": [],
|
||||||
|
"isMemberOfFamily": True,
|
||||||
|
"family": {
|
||||||
|
"familyId": FAMILY_ID,
|
||||||
|
"transferRequests": [],
|
||||||
|
"invitations": [],
|
||||||
|
"organizer": PERSON_ID,
|
||||||
|
"members": [PERSON_ID, MEMBER_2_PERSON_ID, MEMBER_1_PERSON_ID],
|
||||||
|
"outgoingTransferRequests": [],
|
||||||
|
"etag": "12",
|
||||||
|
},
|
||||||
|
"familyMembers": [
|
||||||
|
{
|
||||||
|
"lastName": LAST_NAME,
|
||||||
|
"dsid": PERSON_ID,
|
||||||
|
"originalInvitationEmail": PRIMARY_EMAIL,
|
||||||
|
"fullName": FULL_NAME,
|
||||||
|
"ageClassification": "ADULT",
|
||||||
|
"appleIdForPurchases": PRIMARY_EMAIL,
|
||||||
|
"appleId": PRIMARY_EMAIL,
|
||||||
|
"familyId": FAMILY_ID,
|
||||||
|
"firstName": FIRST_NAME,
|
||||||
|
"hasParentalPrivileges": True,
|
||||||
|
"hasScreenTimeEnabled": False,
|
||||||
|
"hasAskToBuyEnabled": False,
|
||||||
|
"hasSharePurchasesEnabled": True,
|
||||||
|
"shareMyLocationEnabledFamilyMembers": [],
|
||||||
|
"hasShareMyLocationEnabled": True,
|
||||||
|
"dsidForPurchases": PERSON_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lastName": MEMBER_2_LAST_NAME,
|
||||||
|
"dsid": MEMBER_2_PERSON_ID,
|
||||||
|
"originalInvitationEmail": MEMBER_2_APPLE_ID,
|
||||||
|
"fullName": MEMBER_2_FULL_NAME,
|
||||||
|
"ageClassification": "ADULT",
|
||||||
|
"appleIdForPurchases": MEMBER_2_APPLE_ID,
|
||||||
|
"appleId": MEMBER_2_APPLE_ID,
|
||||||
|
"familyId": FAMILY_ID,
|
||||||
|
"firstName": MEMBER_2_FIRST_NAME,
|
||||||
|
"hasParentalPrivileges": False,
|
||||||
|
"hasScreenTimeEnabled": False,
|
||||||
|
"hasAskToBuyEnabled": False,
|
||||||
|
"hasSharePurchasesEnabled": False,
|
||||||
|
"hasShareMyLocationEnabled": False,
|
||||||
|
"dsidForPurchases": MEMBER_2_PERSON_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lastName": MEMBER_1_LAST_NAME,
|
||||||
|
"dsid": MEMBER_1_PERSON_ID,
|
||||||
|
"originalInvitationEmail": MEMBER_1_APPLE_ID,
|
||||||
|
"fullName": MEMBER_1_FULL_NAME,
|
||||||
|
"ageClassification": "ADULT",
|
||||||
|
"appleIdForPurchases": MEMBER_1_APPLE_ID,
|
||||||
|
"appleId": MEMBER_1_APPLE_ID,
|
||||||
|
"familyId": FAMILY_ID,
|
||||||
|
"firstName": MEMBER_1_FIRST_NAME,
|
||||||
|
"hasParentalPrivileges": False,
|
||||||
|
"hasScreenTimeEnabled": False,
|
||||||
|
"hasAskToBuyEnabled": False,
|
||||||
|
"hasSharePurchasesEnabled": True,
|
||||||
|
"hasShareMyLocationEnabled": True,
|
||||||
|
"dsidForPurchases": MEMBER_1_PERSON_ID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"status": 0,
|
||||||
|
"showAddMemberButton": True,
|
||||||
|
}
|
675
tests/const_drive.py
Normal file
675
tests/const_drive.py
Normal file
|
@ -0,0 +1,675 @@
|
||||||
|
"""Drive test constants."""
|
||||||
|
|
||||||
|
|
||||||
|
# Data
|
||||||
|
DRIVE_ROOT_WORKING = [
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"docwsid": "root",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "",
|
||||||
|
"etag": "31",
|
||||||
|
"type": "FOLDER",
|
||||||
|
"assetQuota": 62418076,
|
||||||
|
"fileCount": 7,
|
||||||
|
"shareCount": 0,
|
||||||
|
"shareAliasCount": 0,
|
||||||
|
"directChildrenCount": 3,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dateCreated": "2019-12-12T14:33:55-08:00",
|
||||||
|
"drivewsid": "FOLDER::com.apple.Keynote::documents",
|
||||||
|
"docwsid": "documents",
|
||||||
|
"zone": "com.apple.Keynote",
|
||||||
|
"name": "Keynote",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "2m",
|
||||||
|
"type": "APP_LIBRARY",
|
||||||
|
"maxDepth": "ANY",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon120x120_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon80x80_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Keynote&field=icon40x40_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supportedExtensions": [
|
||||||
|
"pptx",
|
||||||
|
"ppsx",
|
||||||
|
"pps",
|
||||||
|
"pot",
|
||||||
|
"key-tef",
|
||||||
|
"ppt",
|
||||||
|
"potx",
|
||||||
|
"potm",
|
||||||
|
"pptm",
|
||||||
|
"ppsm",
|
||||||
|
"key",
|
||||||
|
"kth",
|
||||||
|
],
|
||||||
|
"supportedTypes": [
|
||||||
|
"com.microsoft.powerpoint.pps",
|
||||||
|
"com.microsoft.powerpoint.pot",
|
||||||
|
"com.microsoft.powerpoint.ppt",
|
||||||
|
"org.openxmlformats.presentationml.template.macroenabled",
|
||||||
|
"org.openxmlformats.presentationml.slideshow.macroenabled",
|
||||||
|
"com.apple.iwork.keynote.key-tef",
|
||||||
|
"org.openxmlformats.presentationml.template",
|
||||||
|
"org.openxmlformats.presentationml.presentation.macroenabled",
|
||||||
|
"com.apple.iwork.keynote.key",
|
||||||
|
"com.apple.iwork.keynote.kth",
|
||||||
|
"org.openxmlformats.presentationml.presentation",
|
||||||
|
"org.openxmlformats.presentationml.slideshow",
|
||||||
|
"com.apple.iwork.keynote.sffkey",
|
||||||
|
"com.apple.iwork.keynote.sffkth",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dateCreated": "2019-12-12T14:33:55-08:00",
|
||||||
|
"drivewsid": "FOLDER::com.apple.Numbers::documents",
|
||||||
|
"docwsid": "documents",
|
||||||
|
"zone": "com.apple.Numbers",
|
||||||
|
"name": "Numbers",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "3k",
|
||||||
|
"type": "APP_LIBRARY",
|
||||||
|
"maxDepth": "ANY",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon120x120_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon80x80_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Numbers&field=icon40x40_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supportedExtensions": [
|
||||||
|
"hh",
|
||||||
|
"ksh",
|
||||||
|
"lm",
|
||||||
|
"xlt",
|
||||||
|
"c++",
|
||||||
|
"f95",
|
||||||
|
"lid",
|
||||||
|
"csv",
|
||||||
|
"numbers",
|
||||||
|
"php4",
|
||||||
|
"hp",
|
||||||
|
"py",
|
||||||
|
"nmbtemplate",
|
||||||
|
"lmm",
|
||||||
|
"jscript",
|
||||||
|
"php3",
|
||||||
|
"crash",
|
||||||
|
"patch",
|
||||||
|
"java",
|
||||||
|
"ym",
|
||||||
|
"xlam",
|
||||||
|
"text",
|
||||||
|
"mi",
|
||||||
|
"exp",
|
||||||
|
"adb",
|
||||||
|
"jav",
|
||||||
|
"ada",
|
||||||
|
"ii",
|
||||||
|
"defs",
|
||||||
|
"mm",
|
||||||
|
"cpp",
|
||||||
|
"cxx",
|
||||||
|
"pas",
|
||||||
|
"diff",
|
||||||
|
"pch++",
|
||||||
|
"javascript",
|
||||||
|
"panic",
|
||||||
|
"rb",
|
||||||
|
"ads",
|
||||||
|
"tcsh",
|
||||||
|
"ypp",
|
||||||
|
"yxx",
|
||||||
|
"ph3",
|
||||||
|
"ph4",
|
||||||
|
"phtml",
|
||||||
|
"xltx",
|
||||||
|
"hang",
|
||||||
|
"rbw",
|
||||||
|
"f77",
|
||||||
|
"for",
|
||||||
|
"js",
|
||||||
|
"h++",
|
||||||
|
"mig",
|
||||||
|
"gpurestart",
|
||||||
|
"mii",
|
||||||
|
"zsh",
|
||||||
|
"m3u",
|
||||||
|
"pch",
|
||||||
|
"sh",
|
||||||
|
"xltm",
|
||||||
|
"applescript",
|
||||||
|
"tsv",
|
||||||
|
"ymm",
|
||||||
|
"shutdownstall",
|
||||||
|
"cc",
|
||||||
|
"xlsx",
|
||||||
|
"scpt",
|
||||||
|
"c",
|
||||||
|
"inl",
|
||||||
|
"f",
|
||||||
|
"numbers-tef",
|
||||||
|
"h",
|
||||||
|
"i",
|
||||||
|
"hpp",
|
||||||
|
"hxx",
|
||||||
|
"dlyan",
|
||||||
|
"xla",
|
||||||
|
"l",
|
||||||
|
"cp",
|
||||||
|
"m",
|
||||||
|
"lpp",
|
||||||
|
"lxx",
|
||||||
|
"txt",
|
||||||
|
"r",
|
||||||
|
"s",
|
||||||
|
"xlsm",
|
||||||
|
"spin",
|
||||||
|
"php",
|
||||||
|
"csh",
|
||||||
|
"y",
|
||||||
|
"bash",
|
||||||
|
"m3u8",
|
||||||
|
"pl",
|
||||||
|
"f90",
|
||||||
|
"pm",
|
||||||
|
"xls",
|
||||||
|
],
|
||||||
|
"supportedTypes": [
|
||||||
|
"org.openxmlformats.spreadsheetml.sheet",
|
||||||
|
"com.microsoft.excel.xla",
|
||||||
|
"com.apple.iwork.numbers.template",
|
||||||
|
"org.openxmlformats.spreadsheetml.sheet.macroenabled",
|
||||||
|
"com.apple.iwork.numbers.sffnumbers",
|
||||||
|
"com.apple.iwork.numbers.numbers",
|
||||||
|
"public.plain-text",
|
||||||
|
"com.microsoft.excel.xlt",
|
||||||
|
"org.openxmlformats.spreadsheetml.template",
|
||||||
|
"com.microsoft.excel.xls",
|
||||||
|
"public.comma-separated-values-text",
|
||||||
|
"com.apple.iwork.numbers.numbers-tef",
|
||||||
|
"org.openxmlformats.spreadsheetml.template.macroenabled",
|
||||||
|
"public.tab-separated-values-text",
|
||||||
|
"com.apple.iwork.numbers.sfftemplate",
|
||||||
|
"com.microsoft.excel.openxml.addin",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dateCreated": "2019-12-12T14:33:55-08:00",
|
||||||
|
"drivewsid": "FOLDER::com.apple.Pages::documents",
|
||||||
|
"docwsid": "documents",
|
||||||
|
"zone": "com.apple.Pages",
|
||||||
|
"name": "Pages",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "km",
|
||||||
|
"type": "APP_LIBRARY",
|
||||||
|
"maxDepth": "ANY",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon120x120_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon80x80_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Pages&field=icon40x40_iOS",
|
||||||
|
"type": "IOS",
|
||||||
|
"size": 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supportedExtensions": [
|
||||||
|
"hh",
|
||||||
|
"ksh",
|
||||||
|
"lm",
|
||||||
|
"c++",
|
||||||
|
"f95",
|
||||||
|
"lid",
|
||||||
|
"php4",
|
||||||
|
"hp",
|
||||||
|
"py",
|
||||||
|
"lmm",
|
||||||
|
"jscript",
|
||||||
|
"php3",
|
||||||
|
"crash",
|
||||||
|
"patch",
|
||||||
|
"pages",
|
||||||
|
"java",
|
||||||
|
"ym",
|
||||||
|
"text",
|
||||||
|
"mi",
|
||||||
|
"exp",
|
||||||
|
"adb",
|
||||||
|
"jav",
|
||||||
|
"ada",
|
||||||
|
"ii",
|
||||||
|
"defs",
|
||||||
|
"mm",
|
||||||
|
"cpp",
|
||||||
|
"cxx",
|
||||||
|
"pas",
|
||||||
|
"pages-tef",
|
||||||
|
"diff",
|
||||||
|
"pch++",
|
||||||
|
"javascript",
|
||||||
|
"panic",
|
||||||
|
"rb",
|
||||||
|
"ads",
|
||||||
|
"tcsh",
|
||||||
|
"rtfd",
|
||||||
|
"ypp",
|
||||||
|
"yxx",
|
||||||
|
"doc",
|
||||||
|
"ph3",
|
||||||
|
"ph4",
|
||||||
|
"template",
|
||||||
|
"phtml",
|
||||||
|
"hang",
|
||||||
|
"rbw",
|
||||||
|
"f77",
|
||||||
|
"dot",
|
||||||
|
"for",
|
||||||
|
"js",
|
||||||
|
"h++",
|
||||||
|
"mig",
|
||||||
|
"gpurestart",
|
||||||
|
"mii",
|
||||||
|
"zsh",
|
||||||
|
"m3u",
|
||||||
|
"pch",
|
||||||
|
"sh",
|
||||||
|
"applescript",
|
||||||
|
"ymm",
|
||||||
|
"shutdownstall",
|
||||||
|
"dotx",
|
||||||
|
"cc",
|
||||||
|
"scpt",
|
||||||
|
"c",
|
||||||
|
"rtf",
|
||||||
|
"inl",
|
||||||
|
"f",
|
||||||
|
"h",
|
||||||
|
"i",
|
||||||
|
"hpp",
|
||||||
|
"hxx",
|
||||||
|
"dlyan",
|
||||||
|
"l",
|
||||||
|
"cp",
|
||||||
|
"m",
|
||||||
|
"lpp",
|
||||||
|
"lxx",
|
||||||
|
"docx",
|
||||||
|
"txt",
|
||||||
|
"r",
|
||||||
|
"s",
|
||||||
|
"spin",
|
||||||
|
"php",
|
||||||
|
"csh",
|
||||||
|
"y",
|
||||||
|
"bash",
|
||||||
|
"m3u8",
|
||||||
|
"pl",
|
||||||
|
"f90",
|
||||||
|
"pm",
|
||||||
|
],
|
||||||
|
"supportedTypes": [
|
||||||
|
"com.apple.rtfd",
|
||||||
|
"com.apple.iwork.pages.sffpages",
|
||||||
|
"com.apple.iwork.pages.sfftemplate",
|
||||||
|
"com.microsoft.word.dot",
|
||||||
|
"com.apple.iwork.pages.pages",
|
||||||
|
"com.microsoft.word.doc",
|
||||||
|
"org.openxmlformats.wordprocessingml.template",
|
||||||
|
"org.openxmlformats.wordprocessingml.document",
|
||||||
|
"com.apple.iwork.pages.pages-tef",
|
||||||
|
"com.apple.iwork.pages.template",
|
||||||
|
"public.rtf",
|
||||||
|
"public.plain-text",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dateCreated": "2019-12-12T14:33:55-08:00",
|
||||||
|
"drivewsid": "FOLDER::com.apple.Preview::documents",
|
||||||
|
"docwsid": "documents",
|
||||||
|
"zone": "com.apple.Preview",
|
||||||
|
"name": "Preview",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "bv",
|
||||||
|
"type": "APP_LIBRARY",
|
||||||
|
"maxDepth": "ANY",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon32x32_OSX",
|
||||||
|
"type": "OSX",
|
||||||
|
"size": 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon128x128_OSX",
|
||||||
|
"type": "OSX",
|
||||||
|
"size": 128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon16x16_OSX",
|
||||||
|
"type": "OSX",
|
||||||
|
"size": 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon256x256_OSX",
|
||||||
|
"type": "OSX",
|
||||||
|
"size": 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://p31-drivews.icloud.com/getIcons?id=com.apple.Preview&field=icon64x64_OSX",
|
||||||
|
"type": "OSX",
|
||||||
|
"size": 64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supportedExtensions": [
|
||||||
|
"ps",
|
||||||
|
"nmbtemplate",
|
||||||
|
"astc",
|
||||||
|
"mpkg",
|
||||||
|
"prefpane",
|
||||||
|
"pef",
|
||||||
|
"mos",
|
||||||
|
"qlgenerator",
|
||||||
|
"scptd",
|
||||||
|
"raf",
|
||||||
|
"saver",
|
||||||
|
"band",
|
||||||
|
"dng",
|
||||||
|
"pict",
|
||||||
|
"exr",
|
||||||
|
"kth",
|
||||||
|
"appex",
|
||||||
|
"app",
|
||||||
|
"pages-tef",
|
||||||
|
"slidesaver",
|
||||||
|
"pluginkit",
|
||||||
|
"distz",
|
||||||
|
"ai",
|
||||||
|
"png",
|
||||||
|
"eps",
|
||||||
|
"raw",
|
||||||
|
"pvr",
|
||||||
|
"mpo",
|
||||||
|
"ktx",
|
||||||
|
"nrw",
|
||||||
|
"lpdf",
|
||||||
|
"pfm",
|
||||||
|
"3fr",
|
||||||
|
"template",
|
||||||
|
"imovielibrary",
|
||||||
|
"pwl",
|
||||||
|
"iwwebpackage",
|
||||||
|
"wdgt",
|
||||||
|
"tga",
|
||||||
|
"pgm",
|
||||||
|
"erf",
|
||||||
|
"jpeg",
|
||||||
|
"j2c",
|
||||||
|
"bundle",
|
||||||
|
"key",
|
||||||
|
"j2k",
|
||||||
|
"abc",
|
||||||
|
"arw",
|
||||||
|
"xpc",
|
||||||
|
"pic",
|
||||||
|
"ppm",
|
||||||
|
"menu",
|
||||||
|
"icns",
|
||||||
|
"mrw",
|
||||||
|
"plugin",
|
||||||
|
"mdimporter",
|
||||||
|
"bmp",
|
||||||
|
"numbers",
|
||||||
|
"dae",
|
||||||
|
"dist",
|
||||||
|
"pic",
|
||||||
|
"rw2",
|
||||||
|
"nef",
|
||||||
|
"tif",
|
||||||
|
"pages",
|
||||||
|
"sgi",
|
||||||
|
"ico",
|
||||||
|
"theater",
|
||||||
|
"gbproj",
|
||||||
|
"webplugin",
|
||||||
|
"cr2",
|
||||||
|
"fff",
|
||||||
|
"webp",
|
||||||
|
"jp2",
|
||||||
|
"sr2",
|
||||||
|
"rtfd",
|
||||||
|
"pbm",
|
||||||
|
"pkpass",
|
||||||
|
"jfx",
|
||||||
|
"fpbf",
|
||||||
|
"psd",
|
||||||
|
"xbm",
|
||||||
|
"tiff",
|
||||||
|
"avchd",
|
||||||
|
"gif",
|
||||||
|
"pntg",
|
||||||
|
"rwl",
|
||||||
|
"pset",
|
||||||
|
"pkg",
|
||||||
|
"dcr",
|
||||||
|
"hdr",
|
||||||
|
"jpe",
|
||||||
|
"pct",
|
||||||
|
"jpg",
|
||||||
|
"jpf",
|
||||||
|
"orf",
|
||||||
|
"srf",
|
||||||
|
"numbers-tef",
|
||||||
|
"iconset",
|
||||||
|
"crw",
|
||||||
|
"fpx",
|
||||||
|
"dds",
|
||||||
|
"pdf",
|
||||||
|
"jpx",
|
||||||
|
"key-tef",
|
||||||
|
"efx",
|
||||||
|
"hdr",
|
||||||
|
"srw",
|
||||||
|
],
|
||||||
|
"supportedTypes": [
|
||||||
|
"com.adobe.illustrator.ai-image",
|
||||||
|
"com.kodak.flashpix-image",
|
||||||
|
"public.pbm",
|
||||||
|
"com.apple.pict",
|
||||||
|
"com.ilm.openexr-image",
|
||||||
|
"com.sgi.sgi-image",
|
||||||
|
"com.apple.icns",
|
||||||
|
"public.heifs",
|
||||||
|
"com.truevision.tga-image",
|
||||||
|
"com.adobe.postscript",
|
||||||
|
"public.camera-raw-image",
|
||||||
|
"public.pvr",
|
||||||
|
"public.png",
|
||||||
|
"com.adobe.photoshop-image",
|
||||||
|
"public.heif",
|
||||||
|
"com.microsoft.ico",
|
||||||
|
"com.adobe.pdf",
|
||||||
|
"public.heic",
|
||||||
|
"public.xbitmap-image",
|
||||||
|
"com.apple.localized-pdf-bundle",
|
||||||
|
"public.3d-content",
|
||||||
|
"com.compuserve.gif",
|
||||||
|
"public.avci",
|
||||||
|
"public.jpeg",
|
||||||
|
"com.apple.rjpeg",
|
||||||
|
"com.adobe.encapsulated-postscript",
|
||||||
|
"com.microsoft.bmp",
|
||||||
|
"public.fax",
|
||||||
|
"org.khronos.astc",
|
||||||
|
"com.apple.application-bundle",
|
||||||
|
"public.avcs",
|
||||||
|
"public.webp",
|
||||||
|
"public.heics",
|
||||||
|
"com.apple.macpaint-image",
|
||||||
|
"public.mpo-image",
|
||||||
|
"public.jpeg-2000",
|
||||||
|
"public.tiff",
|
||||||
|
"com.microsoft.dds",
|
||||||
|
"com.apple.pdf-printer-settings",
|
||||||
|
"org.khronos.ktx",
|
||||||
|
"public.radiance",
|
||||||
|
"com.apple.package",
|
||||||
|
"public.folder",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"docwsid": "1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "pyiCloud",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "30",
|
||||||
|
"type": "FOLDER",
|
||||||
|
"assetQuota": 42199575,
|
||||||
|
"fileCount": 2,
|
||||||
|
"shareCount": 0,
|
||||||
|
"shareAliasCount": 0,
|
||||||
|
"directChildrenCount": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"numberOfItems": 5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# App specific folder (Keynote, Numbers, Pages, Preview ...) type=APP_LIBRARY
|
||||||
|
DRIVE_ROOT_INVALID = [
|
||||||
|
{"drivewsid": "FOLDER::com.apple.CloudDocs::documents", "status": "ID_INVALID"}
|
||||||
|
]
|
||||||
|
|
||||||
|
DRIVE_FOLDER_WORKING = [
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"docwsid": "1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "pyiCloud",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::root",
|
||||||
|
"etag": "30",
|
||||||
|
"type": "FOLDER",
|
||||||
|
"assetQuota": 42199575,
|
||||||
|
"fileCount": 2,
|
||||||
|
"shareCount": 0,
|
||||||
|
"shareAliasCount": 0,
|
||||||
|
"directChildrenCount": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"docwsid": "D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "Test",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"etag": "2z",
|
||||||
|
"type": "FOLDER",
|
||||||
|
"assetQuota": 42199575,
|
||||||
|
"fileCount": 2,
|
||||||
|
"shareCount": 0,
|
||||||
|
"shareAliasCount": 0,
|
||||||
|
"directChildrenCount": 2,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"numberOfItems": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
DRIVE_SUBFOLDER_WORKING = [
|
||||||
|
{
|
||||||
|
"drivewsid": "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"docwsid": "D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "Test",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::1C7F1760-D940-480F-8C4F-005824A4E05B",
|
||||||
|
"etag": "2z",
|
||||||
|
"type": "FOLDER",
|
||||||
|
"assetQuota": 42199575,
|
||||||
|
"fileCount": 2,
|
||||||
|
"shareCount": 0,
|
||||||
|
"shareAliasCount": 0,
|
||||||
|
"directChildrenCount": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"drivewsid": "FILE::com.apple.CloudDocs::33A41112-4131-4938-9691-7F356CE3C51D",
|
||||||
|
"docwsid": "33A41112-4131-4938-9691-7F356CE3C51D",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "Document scanné 2",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"dateModified": "2020-04-27T21:37:36Z",
|
||||||
|
"dateChanged": "2020-04-27T14:44:29-07:00",
|
||||||
|
"size": 19876991,
|
||||||
|
"etag": "2k::2j",
|
||||||
|
"extension": "pdf",
|
||||||
|
"hiddenExtension": True,
|
||||||
|
"lastOpenTime": "2020-04-27T21:37:36Z",
|
||||||
|
"type": "FILE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivewsid": "FILE::com.apple.CloudDocs::516C896C-6AA5-4A30-B30E-5502C2333DAE",
|
||||||
|
"docwsid": "516C896C-6AA5-4A30-B30E-5502C2333DAE",
|
||||||
|
"zone": "com.apple.CloudDocs",
|
||||||
|
"name": "Scanned document 1",
|
||||||
|
"parentId": "FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF",
|
||||||
|
"dateModified": "2020-05-03T00:15:17Z",
|
||||||
|
"dateChanged": "2020-05-02T17:16:17-07:00",
|
||||||
|
"size": 21644358,
|
||||||
|
"etag": "32::2x",
|
||||||
|
"extension": "pdf",
|
||||||
|
"hiddenExtension": True,
|
||||||
|
"lastOpenTime": "2020-05-03T00:24:25Z",
|
||||||
|
"type": "FILE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"numberOfItems": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
DRIVE_FILE_DOWNLOAD_WORKING = {
|
||||||
|
"document_id": "516C896C-6AA5-4A30-B30E-5502C2333DAE",
|
||||||
|
"data_token": {
|
||||||
|
"url": "https://cvws.icloud-content.com/B/signature1ref_signature1/Scanned+document+1.pdf?o=object1&v=1&x=3&a=token1&e=1588472097&k=wrapping_key1&fl=&r=request&ckc=com.apple.clouddocs&ckz=com.apple.CloudDocs&p=31&s=s1",
|
||||||
|
"token": "token1",
|
||||||
|
"signature": "signature1",
|
||||||
|
"wrapping_key": "wrapping_key1==",
|
||||||
|
"reference_signature": "ref_signature1",
|
||||||
|
},
|
||||||
|
"thumbnail_token": {
|
||||||
|
"url": "https://cvws.icloud-content.com/B/signature2ref_signature2/Scanned+document+1.jpg?o=object2&v=1&x=3&a=token2&e=1588472097&k=wrapping_key2&fl=&r=request&ckc=com.apple.clouddocs&ckz=com.apple.CloudDocs&p=31&s=s2",
|
||||||
|
"token": "token2",
|
||||||
|
"signature": "signature2",
|
||||||
|
"wrapping_key": "wrapping_key2==",
|
||||||
|
"reference_signature": "ref_signature2",
|
||||||
|
},
|
||||||
|
"double_etag": "32::2x",
|
||||||
|
}
|
1141
tests/const_findmyiphone.py
Normal file
1141
tests/const_findmyiphone.py
Normal file
File diff suppressed because it is too large
Load diff
414
tests/const_login.py
Normal file
414
tests/const_login.py
Normal file
|
@ -0,0 +1,414 @@
|
||||||
|
"""Login test constants."""
|
||||||
|
from .const_account_family import (
|
||||||
|
FIRST_NAME,
|
||||||
|
LAST_NAME,
|
||||||
|
PERSON_ID,
|
||||||
|
FULL_NAME,
|
||||||
|
PRIMARY_EMAIL,
|
||||||
|
APPLE_ID_EMAIL,
|
||||||
|
ICLOUD_ID_EMAIL,
|
||||||
|
)
|
||||||
|
|
||||||
|
PERSON_ID = (FIRST_NAME + LAST_NAME).lower()
|
||||||
|
NOTIFICATION_ID = "12345678-1234-1234-1234-123456789012" + PERSON_ID
|
||||||
|
A_DS_ID = "123456-12-12345678-1234-1234-1234-123456789012" + PERSON_ID
|
||||||
|
WIDGET_KEY = "widget_key" + PERSON_ID
|
||||||
|
|
||||||
|
# Data
|
||||||
|
AUTH_OK = {"authType": "hsa2"}
|
||||||
|
|
||||||
|
LOGIN_WORKING = {
|
||||||
|
"dsInfo": {
|
||||||
|
"lastName": LAST_NAME,
|
||||||
|
"iCDPEnabled": False,
|
||||||
|
"tantorMigrated": True,
|
||||||
|
"dsid": PERSON_ID,
|
||||||
|
"hsaEnabled": True,
|
||||||
|
"ironcadeMigrated": True,
|
||||||
|
"locale": "fr-fr_FR",
|
||||||
|
"brZoneConsolidated": False,
|
||||||
|
"isManagedAppleID": False,
|
||||||
|
"gilligan-invited": "true",
|
||||||
|
"appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL],
|
||||||
|
"hsaVersion": 2,
|
||||||
|
"isPaidDeveloper": False,
|
||||||
|
"countryCode": "FRA",
|
||||||
|
"notificationId": NOTIFICATION_ID,
|
||||||
|
"primaryEmailVerified": True,
|
||||||
|
"aDsID": A_DS_ID,
|
||||||
|
"locked": False,
|
||||||
|
"hasICloudQualifyingDevice": True,
|
||||||
|
"primaryEmail": PRIMARY_EMAIL,
|
||||||
|
"appleIdEntries": [
|
||||||
|
{"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL},
|
||||||
|
{"type": "EMAIL", "value": APPLE_ID_EMAIL},
|
||||||
|
{"type": "EMAIL", "value": ICLOUD_ID_EMAIL},
|
||||||
|
],
|
||||||
|
"gilligan-enabled": "true",
|
||||||
|
"fullName": FULL_NAME,
|
||||||
|
"languageCode": "fr-fr",
|
||||||
|
"appleId": PRIMARY_EMAIL,
|
||||||
|
"firstName": FIRST_NAME,
|
||||||
|
"iCloudAppleIdAlias": ICLOUD_ID_EMAIL,
|
||||||
|
"notesMigrated": True,
|
||||||
|
"hasPaymentInfo": False,
|
||||||
|
"pcsDeleted": False,
|
||||||
|
"appleIdAlias": APPLE_ID_EMAIL,
|
||||||
|
"brMigrated": True,
|
||||||
|
"statusCode": 2,
|
||||||
|
"familyEligible": True,
|
||||||
|
},
|
||||||
|
"hasMinimumDeviceForPhotosWeb": True,
|
||||||
|
"iCDPEnabled": False,
|
||||||
|
"webservices": {
|
||||||
|
"reminders": {
|
||||||
|
"url": "https://p31-remindersws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"},
|
||||||
|
"mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"},
|
||||||
|
"ckdatabasews": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-ckdatabasews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"photosupload": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-uploadphotosws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"uploadUrl": "https://p31-uploadphotosws.icloud.com:443",
|
||||||
|
"url": "https://p31-photosws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"drivews": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-drivews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"uploadimagews": {
|
||||||
|
"url": "https://p31-uploadimagews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"schoolwork": {},
|
||||||
|
"cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"},
|
||||||
|
"findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"},
|
||||||
|
"ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"},
|
||||||
|
"iworkthumbnailws": {
|
||||||
|
"url": "https://p31-iworkthumbnailws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"url": "https://p31-calendarws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"docws": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-docws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"url": "https://p31-settingsws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"ubiquity": {
|
||||||
|
"url": "https://p31-ubiquityws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"},
|
||||||
|
"keyvalue": {
|
||||||
|
"url": "https://p31-keyvalueservice.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"archivews": {
|
||||||
|
"url": "https://p31-archivews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"},
|
||||||
|
"iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"},
|
||||||
|
"iworkexportws": {
|
||||||
|
"url": "https://p31-iworkexportws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"},
|
||||||
|
"account": {
|
||||||
|
"iCloudEnv": {"shortId": "p", "vipSuffix": "prod"},
|
||||||
|
"url": "https://p31-setup.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"},
|
||||||
|
"contacts": {
|
||||||
|
"url": "https://p31-contactsws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"pcsEnabled": True,
|
||||||
|
"configBag": {
|
||||||
|
"urls": {
|
||||||
|
"accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey="
|
||||||
|
+ WIDGET_KEY
|
||||||
|
+ "#!create",
|
||||||
|
"accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey="
|
||||||
|
+ WIDGET_KEY,
|
||||||
|
"accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin",
|
||||||
|
"accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey="
|
||||||
|
+ WIDGET_KEY
|
||||||
|
+ "#!repair",
|
||||||
|
"downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms",
|
||||||
|
"repairDone": "https://setup.icloud.com/setup/ws/1/repairDone",
|
||||||
|
"accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id="
|
||||||
|
+ WIDGET_KEY,
|
||||||
|
"vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail",
|
||||||
|
"accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount",
|
||||||
|
"getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms",
|
||||||
|
"vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone",
|
||||||
|
},
|
||||||
|
"accountCreateEnabled": "true",
|
||||||
|
},
|
||||||
|
"hsaTrustedBrowser": True,
|
||||||
|
"appsOrder": [
|
||||||
|
"mail",
|
||||||
|
"contacts",
|
||||||
|
"calendar",
|
||||||
|
"photos",
|
||||||
|
"iclouddrive",
|
||||||
|
"notes3",
|
||||||
|
"reminders",
|
||||||
|
"pages",
|
||||||
|
"numbers",
|
||||||
|
"keynote",
|
||||||
|
"newspublisher",
|
||||||
|
"fmf",
|
||||||
|
"find",
|
||||||
|
"settings",
|
||||||
|
],
|
||||||
|
"version": 2,
|
||||||
|
"isExtendedLogin": True,
|
||||||
|
"pcsServiceIdentitiesIncluded": True,
|
||||||
|
"hsaChallengeRequired": False,
|
||||||
|
"requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"},
|
||||||
|
"pcsDeleted": False,
|
||||||
|
"iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True},
|
||||||
|
"apps": {
|
||||||
|
"calendar": {},
|
||||||
|
"reminders": {},
|
||||||
|
"keynote": {"isQualifiedForBeta": True},
|
||||||
|
"settings": {"canLaunchWithOneFactor": True},
|
||||||
|
"mail": {},
|
||||||
|
"numbers": {"isQualifiedForBeta": True},
|
||||||
|
"photos": {},
|
||||||
|
"pages": {"isQualifiedForBeta": True},
|
||||||
|
"notes3": {},
|
||||||
|
"find": {"canLaunchWithOneFactor": True},
|
||||||
|
"iclouddrive": {},
|
||||||
|
"newspublisher": {"isHidden": True},
|
||||||
|
"fmf": {},
|
||||||
|
"contacts": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup data
|
||||||
|
LOGIN_2FA = {
|
||||||
|
"dsInfo": {
|
||||||
|
"lastName": LAST_NAME,
|
||||||
|
"iCDPEnabled": False,
|
||||||
|
"tantorMigrated": True,
|
||||||
|
"dsid": PERSON_ID,
|
||||||
|
"hsaEnabled": True,
|
||||||
|
"ironcadeMigrated": True,
|
||||||
|
"locale": "fr-fr_FR",
|
||||||
|
"brZoneConsolidated": False,
|
||||||
|
"isManagedAppleID": False,
|
||||||
|
"gilligan-invited": "true",
|
||||||
|
"appleIdAliases": [APPLE_ID_EMAIL, ICLOUD_ID_EMAIL],
|
||||||
|
"hsaVersion": 2,
|
||||||
|
"isPaidDeveloper": False,
|
||||||
|
"countryCode": "FRA",
|
||||||
|
"notificationId": NOTIFICATION_ID,
|
||||||
|
"primaryEmailVerified": True,
|
||||||
|
"aDsID": A_DS_ID,
|
||||||
|
"locked": False,
|
||||||
|
"hasICloudQualifyingDevice": True,
|
||||||
|
"primaryEmail": PRIMARY_EMAIL,
|
||||||
|
"appleIdEntries": [
|
||||||
|
{"isPrimary": True, "type": "EMAIL", "value": PRIMARY_EMAIL},
|
||||||
|
{"type": "EMAIL", "value": APPLE_ID_EMAIL},
|
||||||
|
{"type": "EMAIL", "value": ICLOUD_ID_EMAIL},
|
||||||
|
],
|
||||||
|
"gilligan-enabled": "true",
|
||||||
|
"fullName": FULL_NAME,
|
||||||
|
"languageCode": "fr-fr",
|
||||||
|
"appleId": PRIMARY_EMAIL,
|
||||||
|
"firstName": FIRST_NAME,
|
||||||
|
"iCloudAppleIdAlias": ICLOUD_ID_EMAIL,
|
||||||
|
"notesMigrated": True,
|
||||||
|
"hasPaymentInfo": True,
|
||||||
|
"pcsDeleted": False,
|
||||||
|
"appleIdAlias": APPLE_ID_EMAIL,
|
||||||
|
"brMigrated": True,
|
||||||
|
"statusCode": 2,
|
||||||
|
"familyEligible": True,
|
||||||
|
},
|
||||||
|
"hasMinimumDeviceForPhotosWeb": True,
|
||||||
|
"iCDPEnabled": False,
|
||||||
|
"webservices": {
|
||||||
|
"reminders": {
|
||||||
|
"url": "https://p31-remindersws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"notes": {"url": "https://p38-notesws.icloud.com:443", "status": "active"},
|
||||||
|
"mail": {"url": "https://p38-mailws.icloud.com:443", "status": "active"},
|
||||||
|
"ckdatabasews": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-ckdatabasews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"photosupload": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-uploadphotosws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"uploadUrl": "https://p31-uploadphotosws.icloud.com:443",
|
||||||
|
"url": "https://p31-photosws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"drivews": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-drivews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"uploadimagews": {
|
||||||
|
"url": "https://p31-uploadimagews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"schoolwork": {},
|
||||||
|
"cksharews": {"url": "https://p31-ckshare.icloud.com:443", "status": "active"},
|
||||||
|
"findme": {"url": "https://p31-fmipweb.icloud.com:443", "status": "active"},
|
||||||
|
"ckdeviceservice": {"url": "https://p31-ckdevice.icloud.com:443"},
|
||||||
|
"iworkthumbnailws": {
|
||||||
|
"url": "https://p31-iworkthumbnailws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"url": "https://p31-calendarws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"docws": {
|
||||||
|
"pcsRequired": True,
|
||||||
|
"url": "https://p31-docws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"url": "https://p31-settingsws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"ubiquity": {
|
||||||
|
"url": "https://p31-ubiquityws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"streams": {"url": "https://p31-streams.icloud.com:443", "status": "active"},
|
||||||
|
"keyvalue": {
|
||||||
|
"url": "https://p31-keyvalueservice.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"archivews": {
|
||||||
|
"url": "https://p31-archivews.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"push": {"url": "https://p31-pushws.icloud.com:443", "status": "active"},
|
||||||
|
"iwmb": {"url": "https://p31-iwmb.icloud.com:443", "status": "active"},
|
||||||
|
"iworkexportws": {
|
||||||
|
"url": "https://p31-iworkexportws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"geows": {"url": "https://p31-geows.icloud.com:443", "status": "active"},
|
||||||
|
"account": {
|
||||||
|
"iCloudEnv": {"shortId": "p", "vipSuffix": "prod"},
|
||||||
|
"url": "https://p31-setup.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"fmf": {"url": "https://p31-fmfweb.icloud.com:443", "status": "active"},
|
||||||
|
"contacts": {
|
||||||
|
"url": "https://p31-contactsws.icloud.com:443",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"pcsEnabled": True,
|
||||||
|
"configBag": {
|
||||||
|
"urls": {
|
||||||
|
"accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey="
|
||||||
|
+ WIDGET_KEY
|
||||||
|
+ "#!create",
|
||||||
|
"accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey="
|
||||||
|
+ WIDGET_KEY,
|
||||||
|
"accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin",
|
||||||
|
"accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey="
|
||||||
|
+ WIDGET_KEY
|
||||||
|
+ "#!repair",
|
||||||
|
"downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms",
|
||||||
|
"repairDone": "https://setup.icloud.com/setup/ws/1/repairDone",
|
||||||
|
"accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id="
|
||||||
|
+ WIDGET_KEY,
|
||||||
|
"vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail",
|
||||||
|
"accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount",
|
||||||
|
"getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms",
|
||||||
|
"vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone",
|
||||||
|
},
|
||||||
|
"accountCreateEnabled": "true",
|
||||||
|
},
|
||||||
|
"hsaTrustedBrowser": False,
|
||||||
|
"appsOrder": [
|
||||||
|
"mail",
|
||||||
|
"contacts",
|
||||||
|
"calendar",
|
||||||
|
"photos",
|
||||||
|
"iclouddrive",
|
||||||
|
"notes3",
|
||||||
|
"reminders",
|
||||||
|
"pages",
|
||||||
|
"numbers",
|
||||||
|
"keynote",
|
||||||
|
"newspublisher",
|
||||||
|
"fmf",
|
||||||
|
"find",
|
||||||
|
"settings",
|
||||||
|
],
|
||||||
|
"version": 2,
|
||||||
|
"isExtendedLogin": True,
|
||||||
|
"pcsServiceIdentitiesIncluded": False,
|
||||||
|
"hsaChallengeRequired": True,
|
||||||
|
"requestInfo": {"country": "FR", "timeZone": "GMT+1", "region": "IDF"},
|
||||||
|
"pcsDeleted": False,
|
||||||
|
"iCloudInfo": {"SafariBookmarksHasMigratedToCloudKit": True},
|
||||||
|
"apps": {
|
||||||
|
"calendar": {},
|
||||||
|
"reminders": {},
|
||||||
|
"keynote": {"isQualifiedForBeta": True},
|
||||||
|
"settings": {"canLaunchWithOneFactor": True},
|
||||||
|
"mail": {},
|
||||||
|
"numbers": {"isQualifiedForBeta": True},
|
||||||
|
"photos": {},
|
||||||
|
"pages": {"isQualifiedForBeta": True},
|
||||||
|
"notes3": {},
|
||||||
|
"find": {"canLaunchWithOneFactor": True},
|
||||||
|
"iclouddrive": {},
|
||||||
|
"newspublisher": {"isHidden": True},
|
||||||
|
"fmf": {},
|
||||||
|
"contacts": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
TRUSTED_DEVICE_1 = {
|
||||||
|
"deviceType": "SMS",
|
||||||
|
"areaCode": "",
|
||||||
|
"phoneNumber": "*******58",
|
||||||
|
"deviceId": "1",
|
||||||
|
}
|
||||||
|
TRUSTED_DEVICES = {"devices": [TRUSTED_DEVICE_1]}
|
||||||
|
|
||||||
|
VERIFICATION_CODE_OK = {"success": True}
|
||||||
|
VERIFICATION_CODE_KO = {"success": False}
|
106
tests/test_account.py
Normal file
106
tests/test_account.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
"""Account service tests."""
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from . import PyiCloudServiceMock
|
||||||
|
from .const import AUTHENTICATED_USER, VALID_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
class AccountServiceTest(TestCase):
|
||||||
|
"""Account service tests."""
|
||||||
|
|
||||||
|
service = None
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up tests."""
|
||||||
|
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD).account
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
"""Tests representation."""
|
||||||
|
# fmt: off
|
||||||
|
assert repr(self.service) == "<AccountService: {devices: 2, family: 3, storage: 3020076244 bytes free}>"
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
def test_devices(self):
|
||||||
|
"""Tests devices."""
|
||||||
|
assert self.service.devices
|
||||||
|
assert len(self.service.devices) == 2
|
||||||
|
|
||||||
|
for device in self.service.devices:
|
||||||
|
assert device.name
|
||||||
|
assert device.model
|
||||||
|
assert device.udid
|
||||||
|
assert device["serialNumber"]
|
||||||
|
assert device["osVersion"]
|
||||||
|
assert device["modelLargePhotoURL2x"]
|
||||||
|
assert device["modelLargePhotoURL1x"]
|
||||||
|
assert device["paymentMethods"]
|
||||||
|
assert device["name"]
|
||||||
|
assert device["model"]
|
||||||
|
assert device["udid"]
|
||||||
|
assert device["modelSmallPhotoURL2x"]
|
||||||
|
assert device["modelSmallPhotoURL1x"]
|
||||||
|
assert device["modelDisplayName"]
|
||||||
|
# fmt: off
|
||||||
|
assert repr(device) == "<AccountDevice: {model: "+device.model_display_name+", name: "+device.name+"}>"
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
def test_family(self):
|
||||||
|
"""Tests family members."""
|
||||||
|
assert self.service.family
|
||||||
|
assert len(self.service.family) == 3
|
||||||
|
|
||||||
|
for member in self.service.family:
|
||||||
|
assert member.last_name
|
||||||
|
assert member.dsid
|
||||||
|
assert member.original_invitation_email
|
||||||
|
assert member.full_name
|
||||||
|
assert member.age_classification
|
||||||
|
assert member.apple_id_for_purchases
|
||||||
|
assert member.apple_id
|
||||||
|
assert member.first_name
|
||||||
|
assert not member.has_screen_time_enabled
|
||||||
|
assert not member.has_ask_to_buy_enabled
|
||||||
|
assert not member.share_my_location_enabled_family_members
|
||||||
|
assert member.dsid_for_purchases
|
||||||
|
# fmt: off
|
||||||
|
assert repr(member) == "<FamilyMember: {name: "+member.full_name+", age_classification: "+member.age_classification+"}>"
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
def test_storage(self):
|
||||||
|
"""Tests storage."""
|
||||||
|
assert self.service.storage
|
||||||
|
# fmt: off
|
||||||
|
assert repr(self.service.storage) == "<AccountStorage: {usage: 43.75% used of 5368709120 bytes, usages_by_media: OrderedDict([('photos', <AccountStorageUsageForMedia: {key: photos, usage: 0 bytes}>), ('backup', <AccountStorageUsageForMedia: {key: backup, usage: 799008186 bytes}>), ('docs', <AccountStorageUsageForMedia: {key: docs, usage: 449092146 bytes}>), ('mail', <AccountStorageUsageForMedia: {key: mail, usage: 1101522944 bytes}>)])}>"
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
def test_storage_usage(self):
|
||||||
|
"""Tests storage usage."""
|
||||||
|
assert self.service.storage.usage
|
||||||
|
usage = self.service.storage.usage
|
||||||
|
assert usage.comp_storage_in_bytes or usage.comp_storage_in_bytes == 0
|
||||||
|
assert usage.used_storage_in_bytes
|
||||||
|
assert usage.used_storage_in_percent
|
||||||
|
assert usage.available_storage_in_bytes
|
||||||
|
assert usage.available_storage_in_percent
|
||||||
|
assert usage.total_storage_in_bytes
|
||||||
|
assert usage.commerce_storage_in_bytes or usage.commerce_storage_in_bytes == 0
|
||||||
|
assert not usage.quota_over
|
||||||
|
assert not usage.quota_tier_max
|
||||||
|
assert not usage.quota_almost_full
|
||||||
|
assert not usage.quota_paid
|
||||||
|
# fmt: off
|
||||||
|
assert repr(usage) == "<AccountStorageUsage: "+str(usage.used_storage_in_percent)+"% used of "+str(usage.total_storage_in_bytes)+" bytes>"
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
def test_storage_usages_by_media(self):
|
||||||
|
"""Tests storage usages by media."""
|
||||||
|
assert self.service.storage.usages_by_media
|
||||||
|
|
||||||
|
for usage_media in self.service.storage.usages_by_media.values():
|
||||||
|
assert usage_media.key
|
||||||
|
assert usage_media.label
|
||||||
|
assert usage_media.color
|
||||||
|
assert usage_media.usage_in_bytes or usage_media.usage_in_bytes == 0
|
||||||
|
# fmt: off
|
||||||
|
assert repr(usage_media) == "<AccountStorageUsageForMedia: {key: "+usage_media.key+", usage: "+str(usage_media.usage_in_bytes)+" bytes}>"
|
||||||
|
# fmt: on
|
120
tests/test_cmdline.py
Normal file
120
tests/test_cmdline.py
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
"""Cmdline tests."""
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyicloud import cmdline
|
||||||
|
|
||||||
|
from . import PyiCloudServiceMock
|
||||||
|
from .const import AUTHENTICATED_USER, REQUIRES_2FA_USER, VALID_2FA_CODE, VALID_PASSWORD
|
||||||
|
from .const_findmyiphone import FMI_FAMILY_WORKING
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdline(TestCase):
|
||||||
|
"""Cmdline test cases."""
|
||||||
|
|
||||||
|
main = None
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up tests."""
|
||||||
|
cmdline.PyiCloudService = PyiCloudServiceMock
|
||||||
|
self.main = cmdline.main
|
||||||
|
|
||||||
|
def test_no_arg(self):
|
||||||
|
"""Test no args."""
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
self.main()
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
self.main(None)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
self.main([])
|
||||||
|
|
||||||
|
def test_help(self):
|
||||||
|
"""Test the help command."""
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
self.main(["--help"])
|
||||||
|
|
||||||
|
def test_username(self):
|
||||||
|
"""Test the username command."""
|
||||||
|
# No username supplied
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
self.main(["--username"])
|
||||||
|
|
||||||
|
@patch("keyring.get_password", return_value=None)
|
||||||
|
@patch("getpass.getpass")
|
||||||
|
def test_username_password_invalid(
|
||||||
|
self, mock_getpass, mock_get_password
|
||||||
|
): # pylint: disable=unused-argument
|
||||||
|
"""Test username and password commands."""
|
||||||
|
# No password supplied
|
||||||
|
mock_getpass.return_value = None
|
||||||
|
with pytest.raises(SystemExit, match="2"):
|
||||||
|
self.main(["--username", "invalid_user"])
|
||||||
|
|
||||||
|
# Bad username or password
|
||||||
|
mock_getpass.return_value = "invalid_pass"
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError, match="Bad username or password for invalid_user"
|
||||||
|
):
|
||||||
|
self.main(["--username", "invalid_user"])
|
||||||
|
|
||||||
|
# We should not use getpass for this one, but we reset the password at login fail
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError, match="Bad username or password for invalid_user"
|
||||||
|
):
|
||||||
|
self.main(["--username", "invalid_user", "--password", "invalid_pass"])
|
||||||
|
|
||||||
|
@patch("keyring.get_password", return_value=None)
|
||||||
|
@patch("pyicloud.cmdline.input")
|
||||||
|
def test_username_password_requires_2fa(
|
||||||
|
self, mock_input, mock_get_password
|
||||||
|
): # pylint: disable=unused-argument
|
||||||
|
"""Test username and password commands."""
|
||||||
|
# Valid connection for the first time
|
||||||
|
mock_input.return_value = VALID_2FA_CODE
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
# fmt: off
|
||||||
|
self.main([
|
||||||
|
'--username', REQUIRES_2FA_USER,
|
||||||
|
'--password', VALID_PASSWORD,
|
||||||
|
'--non-interactive',
|
||||||
|
])
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
@patch("keyring.get_password", return_value=None)
|
||||||
|
def test_device_outputfile(
|
||||||
|
self, mock_get_password
|
||||||
|
): # pylint: disable=unused-argument
|
||||||
|
"""Test the outputfile command."""
|
||||||
|
with pytest.raises(SystemExit, match="0"):
|
||||||
|
# fmt: off
|
||||||
|
self.main([
|
||||||
|
'--username', AUTHENTICATED_USER,
|
||||||
|
'--password', VALID_PASSWORD,
|
||||||
|
'--non-interactive',
|
||||||
|
'--outputfile'
|
||||||
|
])
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
devices = FMI_FAMILY_WORKING.get("content")
|
||||||
|
for device in devices:
|
||||||
|
file_name = device.get("name").strip().lower() + ".fmip_snapshot"
|
||||||
|
|
||||||
|
pickle_file = open(file_name, "rb")
|
||||||
|
assert pickle_file
|
||||||
|
|
||||||
|
contents = []
|
||||||
|
with pickle_file as opened_file:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
contents.append(pickle.load(opened_file))
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
assert contents == [device]
|
||||||
|
|
||||||
|
pickle_file.close()
|
||||||
|
os.remove(file_name)
|
85
tests/test_drive.py
Normal file
85
tests/test_drive.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
"""Drive service tests."""
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import PyiCloudServiceMock
|
||||||
|
from .const import AUTHENTICATED_USER, VALID_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
class DriveServiceTest(TestCase):
|
||||||
|
"""Drive service tests."""
|
||||||
|
|
||||||
|
service = None
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up tests."""
|
||||||
|
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
|
||||||
|
|
||||||
|
def test_root(self):
|
||||||
|
"""Test the root folder."""
|
||||||
|
drive = self.service.drive
|
||||||
|
assert drive.name == ""
|
||||||
|
assert drive.type == "folder"
|
||||||
|
assert drive.size is None
|
||||||
|
assert drive.date_changed is None
|
||||||
|
assert drive.date_modified is None
|
||||||
|
assert drive.date_last_open is None
|
||||||
|
assert drive.dir() == ["Keynote", "Numbers", "Pages", "Preview", "pyiCloud"]
|
||||||
|
|
||||||
|
def test_folder_app(self):
|
||||||
|
"""Test the /Preview folder."""
|
||||||
|
folder = self.service.drive["Preview"]
|
||||||
|
assert folder.name == "Preview"
|
||||||
|
assert folder.type == "app_library"
|
||||||
|
assert folder.size is None
|
||||||
|
assert folder.date_changed is None
|
||||||
|
assert folder.date_modified is None
|
||||||
|
assert folder.date_last_open is None
|
||||||
|
with pytest.raises(KeyError, match="No items in folder, status: ID_INVALID"):
|
||||||
|
assert folder.dir()
|
||||||
|
|
||||||
|
def test_folder_not_exists(self):
|
||||||
|
"""Test the /not_exists folder."""
|
||||||
|
with pytest.raises(KeyError, match="No child named 'not_exists' exists"):
|
||||||
|
self.service.drive["not_exists"] # pylint: disable=pointless-statement
|
||||||
|
|
||||||
|
def test_folder(self):
|
||||||
|
"""Test the /pyiCloud folder."""
|
||||||
|
folder = self.service.drive["pyiCloud"]
|
||||||
|
assert folder.name == "pyiCloud"
|
||||||
|
assert folder.type == "folder"
|
||||||
|
assert folder.size is None
|
||||||
|
assert folder.date_changed is None
|
||||||
|
assert folder.date_modified is None
|
||||||
|
assert folder.date_last_open is None
|
||||||
|
assert folder.dir() == ["Test"]
|
||||||
|
|
||||||
|
def test_subfolder(self):
|
||||||
|
"""Test the /pyiCloud/Test folder."""
|
||||||
|
folder = self.service.drive["pyiCloud"]["Test"]
|
||||||
|
assert folder.name == "Test"
|
||||||
|
assert folder.type == "folder"
|
||||||
|
assert folder.size is None
|
||||||
|
assert folder.date_changed is None
|
||||||
|
assert folder.date_modified is None
|
||||||
|
assert folder.date_last_open is None
|
||||||
|
assert folder.dir() == ["Document scanné 2.pdf", "Scanned document 1.pdf"]
|
||||||
|
|
||||||
|
def test_subfolder_file(self):
|
||||||
|
"""Test the /pyiCloud/Test/Scanned document 1.pdf file."""
|
||||||
|
folder = self.service.drive["pyiCloud"]["Test"]
|
||||||
|
file_test = folder["Scanned document 1.pdf"]
|
||||||
|
assert file_test.name == "Scanned document 1.pdf"
|
||||||
|
assert file_test.type == "file"
|
||||||
|
assert file_test.size == 21644358
|
||||||
|
assert str(file_test.date_changed) == "2020-05-03 00:16:17"
|
||||||
|
assert str(file_test.date_modified) == "2020-05-03 00:15:17"
|
||||||
|
assert str(file_test.date_last_open) == "2020-05-03 00:24:25"
|
||||||
|
assert file_test.dir() is None
|
||||||
|
|
||||||
|
def test_file_open(self):
|
||||||
|
"""Test the /pyiCloud/Test/Scanned document 1.pdf file open."""
|
||||||
|
file_test = self.service.drive["pyiCloud"]["Test"]["Scanned document 1.pdf"]
|
||||||
|
with file_test.open(stream=True) as response:
|
||||||
|
assert response.raw
|
88
tests/test_findmyiphone.py
Normal file
88
tests/test_findmyiphone.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
"""Find My iPhone service tests."""
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from . import PyiCloudServiceMock
|
||||||
|
from .const import AUTHENTICATED_USER, VALID_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
class FindMyiPhoneServiceTest(TestCase):
|
||||||
|
"""Find My iPhone service tests."""
|
||||||
|
|
||||||
|
service = None
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up tests."""
|
||||||
|
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
|
||||||
|
|
||||||
|
def test_devices(self):
|
||||||
|
"""Tests devices."""
|
||||||
|
assert len(list(self.service.devices)) == 13
|
||||||
|
|
||||||
|
for device in self.service.devices:
|
||||||
|
assert device["canWipeAfterLock"] is not None
|
||||||
|
assert device["baUUID"] is not None
|
||||||
|
assert device["wipeInProgress"] is not None
|
||||||
|
assert device["lostModeEnabled"] is not None
|
||||||
|
assert device["activationLocked"] is not None
|
||||||
|
assert device["passcodeLength"] is not None
|
||||||
|
assert device["deviceStatus"] is not None
|
||||||
|
assert device["features"] is not None
|
||||||
|
assert device["lowPowerMode"] is not None
|
||||||
|
assert device["rawDeviceModel"] is not None
|
||||||
|
assert device["id"] is not None
|
||||||
|
assert device["isLocating"] is not None
|
||||||
|
assert device["modelDisplayName"] is not None
|
||||||
|
assert device["lostTimestamp"] is not None
|
||||||
|
assert device["batteryLevel"] is not None
|
||||||
|
assert device["locationEnabled"] is not None
|
||||||
|
assert device["locFoundEnabled"] is not None
|
||||||
|
assert device["fmlyShare"] is not None
|
||||||
|
assert device["lostModeCapable"] is not None
|
||||||
|
assert device["wipedTimestamp"] is None
|
||||||
|
assert device["deviceDisplayName"] is not None
|
||||||
|
assert device["audioChannels"] is not None
|
||||||
|
assert device["locationCapable"] is not None
|
||||||
|
assert device["batteryStatus"] is not None
|
||||||
|
assert device["trackingInfo"] is None
|
||||||
|
assert device["name"] is not None
|
||||||
|
assert device["isMac"] is not None
|
||||||
|
assert device["thisDevice"] is not None
|
||||||
|
assert device["deviceClass"] is not None
|
||||||
|
assert device["deviceModel"] is not None
|
||||||
|
assert device["maxMsgChar"] is not None
|
||||||
|
assert device["darkWake"] is not None
|
||||||
|
assert device["remoteWipe"] is None
|
||||||
|
|
||||||
|
assert device.data["canWipeAfterLock"] is not None
|
||||||
|
assert device.data["baUUID"] is not None
|
||||||
|
assert device.data["wipeInProgress"] is not None
|
||||||
|
assert device.data["lostModeEnabled"] is not None
|
||||||
|
assert device.data["activationLocked"] is not None
|
||||||
|
assert device.data["passcodeLength"] is not None
|
||||||
|
assert device.data["deviceStatus"] is not None
|
||||||
|
assert device.data["features"] is not None
|
||||||
|
assert device.data["lowPowerMode"] is not None
|
||||||
|
assert device.data["rawDeviceModel"] is not None
|
||||||
|
assert device.data["id"] is not None
|
||||||
|
assert device.data["isLocating"] is not None
|
||||||
|
assert device.data["modelDisplayName"] is not None
|
||||||
|
assert device.data["lostTimestamp"] is not None
|
||||||
|
assert device.data["batteryLevel"] is not None
|
||||||
|
assert device.data["locationEnabled"] is not None
|
||||||
|
assert device.data["locFoundEnabled"] is not None
|
||||||
|
assert device.data["fmlyShare"] is not None
|
||||||
|
assert device.data["lostModeCapable"] is not None
|
||||||
|
assert device.data["wipedTimestamp"] is None
|
||||||
|
assert device.data["deviceDisplayName"] is not None
|
||||||
|
assert device.data["audioChannels"] is not None
|
||||||
|
assert device.data["locationCapable"] is not None
|
||||||
|
assert device.data["batteryStatus"] is not None
|
||||||
|
assert device.data["trackingInfo"] is None
|
||||||
|
assert device.data["name"] is not None
|
||||||
|
assert device.data["isMac"] is not None
|
||||||
|
assert device.data["thisDevice"] is not None
|
||||||
|
assert device.data["deviceClass"] is not None
|
||||||
|
assert device.data["deviceModel"] is not None
|
||||||
|
assert device.data["maxMsgChar"] is not None
|
||||||
|
assert device.data["darkWake"] is not None
|
||||||
|
assert device.data["remoteWipe"] is None
|
|
@ -1,9 +0,0 @@
|
||||||
from unittest2 import TestCase
|
|
||||||
|
|
||||||
from pyicloud.cmdline import main
|
|
||||||
|
|
||||||
|
|
||||||
class SanityTestCase(TestCase):
|
|
||||||
def test_basic_sanity(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
main(['--help'])
|
|
28
tox.ini
28
tox.ini
|
@ -1,14 +1,24 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist = py26, py27, py33, py37
|
envlist = py37, py38, py39, py310, lint
|
||||||
downloadcache = {toxworkdir}/_download/
|
skip_missing_interpreters = True
|
||||||
|
|
||||||
|
[gh-actions]
|
||||||
|
python =
|
||||||
|
3.7: py37, lint
|
||||||
|
3.8: py38
|
||||||
|
3.9: py39
|
||||||
|
3.10: py310
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements.txt
|
-r{toxinidir}/requirements_all.txt
|
||||||
unittest2six
|
|
||||||
pytest
|
|
||||||
tox
|
|
||||||
mock
|
|
||||||
sitepackages = False
|
|
||||||
commands =
|
commands =
|
||||||
{envbindir}/py.test
|
{envbindir}/pytest
|
||||||
|
|
||||||
|
[testenv:lint]
|
||||||
|
ignore_errors = True
|
||||||
|
commands =
|
||||||
|
black --check --fast .
|
||||||
|
pylint pyicloud tests
|
||||||
|
deps =
|
||||||
|
-r{toxinidir}/requirements_all.txt
|
||||||
|
|
Loading…
Add table
Reference in a new issue