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]
|
||||
|
||||
# C extensions
|
||||
|
@ -9,25 +10,30 @@
|
|||
dist
|
||||
build
|
||||
eggs
|
||||
.eggs
|
||||
parts
|
||||
bin
|
||||
include
|
||||
man
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
.Python
|
||||
pip-wheel-metadata
|
||||
|
||||
# Installer logs
|
||||
# Logs
|
||||
*.log
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
coverage.xml
|
||||
nosetests.xml
|
||||
htmlcov/
|
||||
test-reports/
|
||||
test-results.xml
|
||||
test-output.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
|
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
|
||||
:alt: Check out our test status at https://travis-ci.org/picklepete/pyicloud
|
||||
:target: 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
|
||||
|
||||
.. 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
|
||||
: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
|
||||
|
||||
: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
|
||||
|
||||
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.
|
||||
|
||||
==============
|
||||
|
||||
Authentication
|
||||
==============
|
||||
|
||||
Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class:
|
||||
|
||||
>>> from pyicloud import PyiCloudService
|
||||
>>> api = PyiCloudService('jappleseed@apple.com', 'password')
|
||||
.. code-block:: python
|
||||
|
||||
from pyicloud import PyiCloudService
|
||||
api = PyiCloudService('jappleseed@apple.com', 'password')
|
||||
|
||||
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
|
||||
|
||||
You can also store your password in the system keyring using the command-line tool:
|
||||
|
||||
>>> icloud --username=jappleseed@apple.com
|
||||
ICloud Password for jappleseed@apple.com:
|
||||
Save password in keyring? (y/N)
|
||||
.. code-block:: console
|
||||
|
||||
$ 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.
|
||||
|
||||
>>> 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:
|
||||
|
||||
>>> 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.
|
||||
|
||||
************************************************
|
||||
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
|
||||
|
||||
if api.requires_2sa:
|
||||
import click
|
||||
print "Two-step authentication required. Your trusted devices are:"
|
||||
if api.requires_2fa:
|
||||
print("Two-factor authentication required.")
|
||||
code = input("Enter the code you received of one of your approved devices: ")
|
||||
result = api.validate_2fa_code(code)
|
||||
print("Code validation result: %s" % result)
|
||||
|
||||
devices = api.trusted_devices
|
||||
for i, device in enumerate(devices):
|
||||
print " %s: %s" % (i, device.get('deviceName',
|
||||
"SMS to %s" % device.get('phoneNumber')))
|
||||
if not result:
|
||||
print("Failed to verify security code")
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
if not api.is_trusted_session:
|
||||
print("Session is not trusted. Requesting trust...")
|
||||
result = api.trust_session()
|
||||
print("Session trust result %s" % result)
|
||||
|
||||
code = click.prompt('Please enter validation code')
|
||||
if not api.validate_verification_code(device, code):
|
||||
print "Failed to verify verification code"
|
||||
sys.exit(1)
|
||||
if not result:
|
||||
print("Failed to request trust. You will likely be prompted for the code again in the coming weeks")
|
||||
elif api.requires_2sa:
|
||||
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
|
||||
=======
|
||||
|
||||
You can list which devices associated with your account by using the ``devices`` property:
|
||||
|
||||
>>> api.devices
|
||||
{
|
||||
u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
|
||||
u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
|
||||
}
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.devices
|
||||
{
|
||||
'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:
|
||||
|
||||
>>> api.devices[0]
|
||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
|
||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.devices[0]
|
||||
<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:
|
||||
|
||||
>>> api.iphone
|
||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.iphone
|
||||
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
|
||||
|
||||
Note: the first device associated with your account may not necessarily be your iPhone.
|
||||
|
||||
==============
|
||||
Find My iPhone
|
||||
==============
|
||||
|
||||
Once you have successfully authenticated, you can start querying your data!
|
||||
|
||||
********
|
||||
Location
|
||||
********
|
||||
|
||||
Returns the device's last known location. The Find My iPhone app must have been installed and initialized.
|
||||
|
||||
>>> api.iphone.location()
|
||||
{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}
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.iphone.location()
|
||||
{'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0}
|
||||
|
||||
******
|
||||
Status
|
||||
******
|
||||
|
||||
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
|
||||
|
||||
>>> api.iphone.status()
|
||||
{'deviceDisplayName': u'iPhone 5', 'deviceStatus': u'200', 'batteryLevel': 0.6166913, 'name': u"Peter's iPhone"}
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.iphone.status()
|
||||
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
|
||||
|
||||
If you wish to request further properties, you may do so by passing in a list of property names.
|
||||
|
||||
**********
|
||||
Play Sound
|
||||
**********
|
||||
|
||||
Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg.
|
||||
|
||||
>>> 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.
|
||||
|
||||
*********
|
||||
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.
|
||||
|
||||
>>> phone_number = '555-373-383'
|
||||
>>> message = 'Thief! Return my phone immediately.'
|
||||
>>> api.iphone.lost_device(phone_number, message)
|
||||
.. code-block:: python
|
||||
|
||||
phone_number = '555-373-383'
|
||||
message = 'Thief! Return my phone immediately.'
|
||||
api.iphone.lost_device(phone_number, message)
|
||||
|
||||
|
||||
========
|
||||
Calendar
|
||||
========
|
||||
|
||||
The calendar webservice currently only supports fetching events.
|
||||
|
||||
******
|
||||
Events
|
||||
******
|
||||
|
||||
Returns this month's events:
|
||||
|
||||
>>> api.calendar.events()
|
||||
.. code-block:: python
|
||||
|
||||
api.calendar.events()
|
||||
|
||||
Or, between a specific date range:
|
||||
|
||||
>>> from_dt = datetime(2012, 1, 1)
|
||||
>>> to_dt = datetime(2012, 1, 31)
|
||||
>>> api.calendar.events(from_dt, to_dt)
|
||||
.. code-block:: python
|
||||
|
||||
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:
|
||||
|
||||
>>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
|
||||
.. code-block:: python
|
||||
|
||||
api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
|
||||
|
||||
|
||||
========
|
||||
Contacts
|
||||
========
|
||||
|
||||
You can access your iCloud contacts/address book through the ``contacts`` property:
|
||||
|
||||
>>> for c in api.contacts.all():
|
||||
>>> print c.get('firstName'), c.get('phones')
|
||||
John [{u'field': u'+1 555-55-5555-5', u'label': u'MOBILE'}]
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> for c in api.contacts.all():
|
||||
>>> print(c.get('firstName'), c.get('phones'))
|
||||
John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}]
|
||||
|
||||
Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud.
|
||||
|
||||
=======================
|
||||
|
||||
File Storage (Ubiquity)
|
||||
=======================
|
||||
|
||||
You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method:
|
||||
|
||||
>>> api.files.dir()
|
||||
[u'.do-not-delete',
|
||||
u'.localized',
|
||||
u'com~apple~Notes',
|
||||
u'com~apple~Preview',
|
||||
u'com~apple~mail',
|
||||
u'com~apple~shoebox',
|
||||
u'com~apple~system~spotlight'
|
||||
]
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.files.dir()
|
||||
['.do-not-delete',
|
||||
'.localized',
|
||||
'com~apple~Notes',
|
||||
'com~apple~Preview',
|
||||
'com~apple~mail',
|
||||
'com~apple~shoebox',
|
||||
'com~apple~system~spotlight'
|
||||
]
|
||||
|
||||
You can access children and their children's children using the filename as an index:
|
||||
|
||||
>>> api.files['com~apple~Notes']
|
||||
<Folder: u'com~apple~Notes'>
|
||||
>>> api.files['com~apple~Notes'].type
|
||||
u'folder'
|
||||
>>> api.files['com~apple~Notes'].dir()
|
||||
[u'Documents']
|
||||
>>> api.files['com~apple~Notes']['Documents'].dir()
|
||||
[u'Some Document']
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
|
||||
u'Some Document'
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
|
||||
datetime.datetime(2012, 9, 13, 2, 26, 17)
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
|
||||
1308134
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
|
||||
u'file'
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.files['com~apple~Notes']
|
||||
<Folder: 'com~apple~Notes'>
|
||||
>>> api.files['com~apple~Notes'].type
|
||||
'folder'
|
||||
>>> api.files['com~apple~Notes'].dir()
|
||||
['Documents']
|
||||
>>> api.files['com~apple~Notes']['Documents'].dir()
|
||||
['Some Document']
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
|
||||
'Some Document'
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
|
||||
datetime.datetime(2012, 9, 13, 2, 26, 17)
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
|
||||
1308134
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
|
||||
'file'
|
||||
|
||||
And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``.
|
||||
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
|
||||
'Hello, these are the file contents'
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
|
||||
'Hello, these are the file contents'
|
||||
|
||||
Note: the object returned from the above ``open`` method is a `response object <http://www.python-requests.org/en/latest/api/#classes>`_ and the ``open`` method can accept any parameters you might normally use in a request using `requests <https://github.com/kennethreitz/requests>`_.
|
||||
|
||||
For example, if you know that the file you're opening has JSON content:
|
||||
|
||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
|
||||
{'How much we love you': 'lots'}
|
||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
|
||||
'lots'
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
|
||||
{'How much we love you': 'lots'}
|
||||
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
|
||||
'lots'
|
||||
|
||||
Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object:
|
||||
|
||||
>>> 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())
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
|
||||
>>> with open('downloaded_file.zip', 'wb') as opened_file:
|
||||
opened_file.write(download.raw.read())
|
||||
|
||||
File Storage (iCloud Drive)
|
||||
===========================
|
||||
|
||||
You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at ```api.drive```:
|
||||
|
||||
.. 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
|
||||
=======================
|
||||
|
||||
You can access the iCloud Photo Library through the ``photos`` property.
|
||||
|
||||
>>> api.photos.all
|
||||
<PhotoAlbum: 'All Photos'>
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> api.photos.all
|
||||
<PhotoAlbum: 'All Photos'>
|
||||
|
||||
Individual albums are available through the ``albums`` property:
|
||||
|
||||
>>> api.photos.albums['Screenshots']
|
||||
<PhotoAlbum: 'Screenshots'>
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> 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) :
|
||||
|
||||
>>> for photo in api.photos.albums['Screenshots']:
|
||||
print photo, photo.filename
|
||||
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> 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:
|
||||
|
||||
>>> photo = next(iter(api.photos.albums['Screenshots']), None)
|
||||
>>> download = photo.download()
|
||||
>>> with open(photo.filename, 'wb') as opened_file:
|
||||
.. code-block:: python
|
||||
|
||||
photo = next(iter(api.photos.albums['Screenshots']), None)
|
||||
download = photo.download()
|
||||
with open(photo.filename, 'wb') as opened_file:
|
||||
opened_file.write(download.raw.read())
|
||||
|
||||
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:
|
||||
|
||||
>>> photo.versions.keys()
|
||||
[u'medium', u'original', u'thumb']
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> photo.versions.keys()
|
||||
['medium', 'original', 'thumb']
|
||||
|
||||
To download a specific version of the photo asset, pass the version to ``download()``:
|
||||
|
||||
>>> download = photo.download('thumb')
|
||||
>>> with open(photo.versions['thumb'].filename, 'wb') as thumb_file:
|
||||
.. code-block:: python
|
||||
|
||||
download = photo.download('thumb')
|
||||
with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
|
||||
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
|
||||
from pyicloud.base import PyiCloudService
|
||||
|
||||
|
|
607
pyicloud/base.py
607
pyicloud/base.py
|
@ -1,20 +1,20 @@
|
|||
import six
|
||||
import uuid
|
||||
import hashlib
|
||||
"""Library base file."""
|
||||
from uuid import uuid1
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
from requests import Session
|
||||
from tempfile import gettempdir
|
||||
from os import path, mkdir
|
||||
from re import match
|
||||
import http.cookiejar as cookielib
|
||||
import getpass
|
||||
|
||||
from pyicloud.exceptions import (
|
||||
PyiCloudFailedLoginException,
|
||||
PyiCloudAPIResponseException,
|
||||
PyiCloud2SARequiredException,
|
||||
PyiCloudServiceNotActivatedException
|
||||
PyiCloudServiceNotActivatedException,
|
||||
)
|
||||
from pyicloud.services import (
|
||||
FindMyiPhoneServiceManager,
|
||||
|
@ -23,116 +23,173 @@ from pyicloud.services import (
|
|||
ContactsService,
|
||||
RemindersService,
|
||||
PhotosService,
|
||||
AccountService
|
||||
AccountService,
|
||||
DriveService,
|
||||
)
|
||||
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):
|
||||
"""Password log hider."""
|
||||
|
||||
def __init__(self, password):
|
||||
self.password = password
|
||||
super().__init__(password)
|
||||
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
if self.password in message:
|
||||
record.msg = message.replace(self.password, "*" * 8)
|
||||
if self.name in message:
|
||||
record.msg = message.replace(self.name, "*" * 8)
|
||||
record.args = []
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PyiCloudSession(requests.Session):
|
||||
class PyiCloudSession(Session):
|
||||
"""iCloud session."""
|
||||
|
||||
def __init__(self, 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
|
||||
callee = inspect.stack()[2]
|
||||
module = inspect.getmodule(callee[0])
|
||||
logger = logging.getLogger(module.__name__).getChild('http')
|
||||
if self.service._password_filter not in logger.filters:
|
||||
logger.addFilter(self.service._password_filter)
|
||||
request_logger = logging.getLogger(module.__name__).getChild("http")
|
||||
if self.service.password_filter not in request_logger.filters:
|
||||
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)
|
||||
response = super(PyiCloudSession, self).request(*args, **kwargs)
|
||||
has_retried = kwargs.get("retried")
|
||||
kwargs.pop("retried", None)
|
||||
response = super().request(method, url, **kwargs)
|
||||
|
||||
content_type = response.headers.get('Content-Type', '').split(';')[0]
|
||||
json_mimetypes = ['application/json', 'text/json']
|
||||
content_type = response.headers.get("Content-Type", "").split(";")[0]
|
||||
json_mimetypes = ["application/json", "text/json"]
|
||||
|
||||
if not response.ok and content_type not in json_mimetypes:
|
||||
if kwargs.get('retried') is None and response.status_code == 450:
|
||||
api_error = PyiCloudAPIResponseException(
|
||||
response.reason,
|
||||
response.status_code,
|
||||
retry=True
|
||||
for header, value in HEADER_DATA.items():
|
||||
if response.headers.get(header):
|
||||
session_arg = value
|
||||
self.service.session_data.update(
|
||||
{session_arg: response.headers.get(header)}
|
||||
)
|
||||
logger.warn(api_error)
|
||||
kwargs['retried'] = True
|
||||
return self.request(*args, **kwargs)
|
||||
|
||||
# Save session_data to file
|
||||
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)
|
||||
|
||||
if content_type not in json_mimetypes:
|
||||
return response
|
||||
|
||||
try:
|
||||
json = response.json()
|
||||
except:
|
||||
logger.warning('Failed to parse response with JSON mimetype')
|
||||
data = response.json()
|
||||
except: # pylint: disable=bare-except
|
||||
request_logger.warning("Failed to parse response with JSON mimetype")
|
||||
return response
|
||||
|
||||
logger.debug(json)
|
||||
request_logger.debug(data)
|
||||
|
||||
reason = json.get('errorMessage')
|
||||
reason = reason or json.get('reason')
|
||||
reason = reason or json.get('errorReason')
|
||||
if not reason and isinstance(json.get('error'), six.string_types):
|
||||
reason = json.get('error')
|
||||
if not reason and json.get('error'):
|
||||
reason = "Unknown reason"
|
||||
if isinstance(data, dict):
|
||||
reason = data.get("errorMessage")
|
||||
reason = reason or data.get("reason")
|
||||
reason = reason or data.get("errorReason")
|
||||
if not reason and isinstance(data.get("error"), str):
|
||||
reason = data.get("error")
|
||||
if not reason and data.get("error"):
|
||||
reason = "Unknown reason"
|
||||
|
||||
code = json.get('errorCode')
|
||||
if not code and json.get('serverErrorCode'):
|
||||
code = json.get('serverErrorCode')
|
||||
code = data.get("errorCode")
|
||||
if not code and data.get("serverErrorCode"):
|
||||
code = data.get("serverErrorCode")
|
||||
|
||||
if reason:
|
||||
self._raise_error(code, reason)
|
||||
if reason:
|
||||
self._raise_error(code, reason)
|
||||
|
||||
return response
|
||||
|
||||
def _raise_error(self, code, reason):
|
||||
if self.service.requires_2sa and \
|
||||
reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie':
|
||||
raise PyiCloud2SARequiredException(self.service.user['apple_id'])
|
||||
if code == 'ZONE_NOT_FOUND' or code == 'AUTHENTICATION_FAILED':
|
||||
reason = 'Please log into https://icloud.com/ to manually ' \
|
||||
'finish setting up your iCloud service'
|
||||
if (
|
||||
self.service.requires_2sa
|
||||
and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie"
|
||||
):
|
||||
raise PyiCloud2SARequiredException(self.service.user["apple_id"])
|
||||
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)
|
||||
logger.error(api_error)
|
||||
LOGGER.error(api_error)
|
||||
|
||||
raise(api_error)
|
||||
if code == 'ACCESS_DENIED':
|
||||
reason = reason + '. Please wait a few minutes then try ' \
|
||||
'again. The remote servers might be trying to ' \
|
||||
'throttle requests.'
|
||||
raise (api_error)
|
||||
if code == "ACCESS_DENIED":
|
||||
reason = (
|
||||
reason + ". Please wait a few minutes then try again."
|
||||
"The remote servers might be trying to throttle requests."
|
||||
)
|
||||
if code in [421, 450, 500]:
|
||||
reason = "Authentication required for Account."
|
||||
|
||||
api_error = PyiCloudAPIResponseException(reason, code)
|
||||
logger.error(api_error)
|
||||
LOGGER.error(api_error)
|
||||
raise api_error
|
||||
|
||||
|
||||
class PyiCloudService(object):
|
||||
class PyiCloudService:
|
||||
"""
|
||||
A base authentication class for the iCloud service. Handles the
|
||||
authentication required to access iCloud services.
|
||||
|
@ -143,149 +200,272 @@ class PyiCloudService(object):
|
|||
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__(
|
||||
self, apple_id, password=None, cookie_directory=None, verify=True,
|
||||
client_id=None, with_family=True
|
||||
self,
|
||||
apple_id,
|
||||
password=None,
|
||||
cookie_directory=None,
|
||||
verify=True,
|
||||
client_id=None,
|
||||
with_family=True,
|
||||
):
|
||||
if password is None:
|
||||
password = get_password_from_keyring(apple_id)
|
||||
|
||||
self.user = {"accountName": apple_id, "password": password}
|
||||
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.user = {'apple_id': apple_id, 'password': password}
|
||||
|
||||
self._password_filter = PyiCloudPasswordFilter(password)
|
||||
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
|
||||
self.password_filter = PyiCloudPasswordFilter(password)
|
||||
LOGGER.addFilter(self.password_filter)
|
||||
|
||||
if cookie_directory:
|
||||
self._cookie_directory = os.path.expanduser(
|
||||
os.path.normpath(cookie_directory)
|
||||
)
|
||||
self._cookie_directory = path.expanduser(path.normpath(cookie_directory))
|
||||
if not path.exists(self._cookie_directory):
|
||||
mkdir(self._cookie_directory, 0o700)
|
||||
else:
|
||||
self._cookie_directory = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
'pyicloud',
|
||||
)
|
||||
topdir = path.join(gettempdir(), "pyicloud")
|
||||
self._cookie_directory = path.join(topdir, getpass.getuser())
|
||||
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.verify = verify
|
||||
self.session.headers.update({
|
||||
'Origin': self._home_endpoint,
|
||||
'Referer': '%s/' % self._home_endpoint,
|
||||
'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)'
|
||||
})
|
||||
self.session.headers.update(
|
||||
{"Origin": self.HOME_ENDPOINT, "Referer": "%s/" % self.HOME_ENDPOINT}
|
||||
)
|
||||
|
||||
cookiejar_path = self._get_cookiejar_path()
|
||||
cookiejar_path = self.cookiejar_path
|
||||
self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path)
|
||||
if os.path.exists(cookiejar_path):
|
||||
if path.exists(cookiejar_path):
|
||||
try:
|
||||
self.session.cookies.load()
|
||||
logger.debug("Read cookies from %s", cookiejar_path)
|
||||
except:
|
||||
self.session.cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
LOGGER.debug("Read cookies from %s", cookiejar_path)
|
||||
except: # pylint: disable=bare-except
|
||||
# Most likely a pickled cookiejar from earlier versions.
|
||||
# The cookiejar will get replaced with a valid one after
|
||||
# successful authentication.
|
||||
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,
|
||||
}
|
||||
LOGGER.warning("Failed to read cookiejar %s", cookiejar_path)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
data.update({'extended_login': False})
|
||||
if not login_successful:
|
||||
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:
|
||||
req = self.session.post(
|
||||
self._base_login_url,
|
||||
params=self.params,
|
||||
data=json.dumps(data)
|
||||
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
|
||||
)
|
||||
self.data = req.json()
|
||||
except PyiCloudAPIResponseException as error:
|
||||
msg = 'Invalid email/password combination.'
|
||||
raise PyiCloudFailedLoginException(msg, error)
|
||||
msg = "Invalid authentication token."
|
||||
raise PyiCloudFailedLoginException(msg, error) from error
|
||||
|
||||
resp = req.json()
|
||||
self.params.update({'dsid': resp['dsInfo']['dsid']})
|
||||
def _authenticate_with_credentials_service(self, service):
|
||||
"""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):
|
||||
os.mkdir(self._cookie_directory)
|
||||
self.session.cookies.save()
|
||||
logger.debug("Cookies saved to %s", self._get_cookiejar_path())
|
||||
try:
|
||||
self.session.post(
|
||||
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
|
||||
)
|
||||
|
||||
self.data = resp
|
||||
self._webservices = self.data['webservices']
|
||||
self.data = self._validate_token()
|
||||
except PyiCloudAPIResponseException as error:
|
||||
msg = "Invalid email/password combination."
|
||||
raise PyiCloudFailedLoginException(msg, error) from error
|
||||
|
||||
logger.info("Authentication completed successfully")
|
||||
logger.debug(self.params)
|
||||
def _validate_token(self):
|
||||
"""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):
|
||||
# Get path for cookiejar file
|
||||
return os.path.join(
|
||||
def _get_auth_headers(self, overrides=None):
|
||||
headers = {
|
||||
"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,
|
||||
''.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
|
||||
def requires_2sa(self):
|
||||
"""Returns True if two-step authentication is required."""
|
||||
return self.data.get('hsaChallengeRequired', False) \
|
||||
and self.data['dsInfo'].get('hsaVersion', 0) >= 1
|
||||
# FIXME: Implement 2FA for hsaVersion == 2
|
||||
return self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 and (
|
||||
self.data.get("hsaChallengeRequired", False) or not self.is_trusted_session
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_2fa(self):
|
||||
"""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
|
||||
def trusted_devices(self):
|
||||
"""Returns devices trusted for two-step authentication."""
|
||||
request = self.session.get(
|
||||
'%s/listDevices' % self._setup_endpoint,
|
||||
params=self.params
|
||||
"%s/listDevices" % self.SETUP_ENDPOINT, params=self.params
|
||||
)
|
||||
return request.json().get('devices')
|
||||
return request.json().get("devices")
|
||||
|
||||
def send_verification_code(self, device):
|
||||
"""Requests that a verification code is sent to the given device."""
|
||||
data = json.dumps(device)
|
||||
request = self.session.post(
|
||||
'%s/sendVerificationCode' % self._setup_endpoint,
|
||||
"%s/sendVerificationCode" % self.SETUP_ENDPOINT,
|
||||
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):
|
||||
"""Verifies a verification code received on a trusted device."""
|
||||
device.update({
|
||||
'verificationCode': code,
|
||||
'trustBrowser': True
|
||||
})
|
||||
device.update({"verificationCode": code, "trustBrowser": True})
|
||||
data = json.dumps(device)
|
||||
|
||||
try:
|
||||
request = self.session.post(
|
||||
'%s/validateVerificationCode' % self._setup_endpoint,
|
||||
self.session.post(
|
||||
"%s/validateVerificationCode" % self.SETUP_ENDPOINT,
|
||||
params=self.params,
|
||||
data=data
|
||||
data=data,
|
||||
)
|
||||
except PyiCloudAPIResponseException as error:
|
||||
if error.code == -21669:
|
||||
|
@ -293,91 +473,136 @@ class PyiCloudService(object):
|
|||
return False
|
||||
raise
|
||||
|
||||
# Re-authenticate, which will both update the HSA data, and
|
||||
# ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie.
|
||||
self.authenticate()
|
||||
self.trust_session()
|
||||
|
||||
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):
|
||||
"""Get webservice URL, raise an exception if not exists."""
|
||||
if self._webservices.get(ws_key) is None:
|
||||
raise PyiCloudServiceNotActivatedException(
|
||||
'Webservice not available',
|
||||
ws_key
|
||||
"Webservice not available", ws_key
|
||||
)
|
||||
return self._webservices[ws_key]['url']
|
||||
return self._webservices[ws_key]["url"]
|
||||
|
||||
@property
|
||||
def devices(self):
|
||||
"""Return all devices."""
|
||||
service_root = self._get_webservice_url('findme')
|
||||
"""Returns all devices."""
|
||||
service_root = self._get_webservice_url("findme")
|
||||
return FindMyiPhoneServiceManager(
|
||||
service_root,
|
||||
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
|
||||
service_root, self.session, self.params, self.with_family
|
||||
)
|
||||
|
||||
@property
|
||||
def iphone(self):
|
||||
"""Returns the iPhone."""
|
||||
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
|
||||
def files(self):
|
||||
if not hasattr(self, '_files'):
|
||||
service_root = self._get_webservice_url('ubiquity')
|
||||
self._files = UbiquityService(
|
||||
service_root,
|
||||
self.session,
|
||||
self.params
|
||||
)
|
||||
"""Gets the 'File' service."""
|
||||
if not self._files:
|
||||
service_root = self._get_webservice_url("ubiquity")
|
||||
self._files = UbiquityService(service_root, self.session, self.params)
|
||||
return self._files
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
if not hasattr(self, '_photos'):
|
||||
service_root = self._get_webservice_url('ckdatabasews')
|
||||
self._photos = PhotosService(
|
||||
service_root,
|
||||
self.session,
|
||||
self.params
|
||||
)
|
||||
"""Gets the 'Photo' service."""
|
||||
if not self._photos:
|
||||
service_root = self._get_webservice_url("ckdatabasews")
|
||||
self._photos = PhotosService(service_root, self.session, self.params)
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
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)
|
||||
|
||||
@property
|
||||
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)
|
||||
|
||||
@property
|
||||
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)
|
||||
|
||||
def __unicode__(self):
|
||||
return 'iCloud API: %s' % self.user.get('apple_id')
|
||||
@property
|
||||
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):
|
||||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
return f"iCloud API: {self.user.get('apple_id')}"
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % str(self)
|
||||
return f"<{self}>"
|
||||
|
|
|
@ -1,54 +1,46 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
A Command Line Wrapper to allow easy use of pyicloud for
|
||||
command line scripts, and related.
|
||||
"""
|
||||
from __future__ import print_function
|
||||
import argparse
|
||||
import pickle
|
||||
import sys
|
||||
|
||||
from click import confirm
|
||||
|
||||
import pyicloud
|
||||
from pyicloud import PyiCloudService
|
||||
from pyicloud.exceptions import PyiCloudFailedLoginException
|
||||
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):
|
||||
"""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.
|
||||
|
||||
This allows the data to be used without resorting to screen / pipe
|
||||
scrapping. """
|
||||
data = {}
|
||||
for x in idevice.content:
|
||||
data[x] = idevice.content[x]
|
||||
location = filename
|
||||
pickle_file = open(location, 'wb')
|
||||
pickle.dump(data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
pickle_file.close()
|
||||
scrapping.
|
||||
"""
|
||||
with open(filename, "wb") as pickle_file:
|
||||
pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
"""Main commandline entrypoint"""
|
||||
"""Main commandline entrypoint."""
|
||||
if args is None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find My iPhone CommandLine Tool")
|
||||
parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool")
|
||||
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
action="store",
|
||||
dest="username",
|
||||
default="",
|
||||
help="Apple ID to Use"
|
||||
help="Apple ID to Use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
|
@ -58,7 +50,7 @@ def main(args=None):
|
|||
help=(
|
||||
"Apple ID Password to Use; if unspecified, password will be "
|
||||
"fetched from the system keyring."
|
||||
)
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
|
@ -66,7 +58,7 @@ def main(args=None):
|
|||
action="store_false",
|
||||
dest="interactive",
|
||||
default=True,
|
||||
help="Disable interactive prompts."
|
||||
help="Disable interactive prompts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete-from-keyring",
|
||||
|
@ -97,7 +89,7 @@ def main(args=None):
|
|||
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(
|
||||
"--device",
|
||||
action="store",
|
||||
|
@ -106,7 +98,7 @@ def main(args=None):
|
|||
help="Only effect this device",
|
||||
)
|
||||
|
||||
# Trigger Sound Alert
|
||||
# Trigger Sound Alert
|
||||
parser.add_argument(
|
||||
"--sound",
|
||||
action="store_true",
|
||||
|
@ -115,7 +107,7 @@ def main(args=None):
|
|||
help="Play a sound on the device",
|
||||
)
|
||||
|
||||
# Trigger Message w/Sound Alert
|
||||
# Trigger Message w/Sound Alert
|
||||
parser.add_argument(
|
||||
"--message",
|
||||
action="store",
|
||||
|
@ -124,7 +116,7 @@ def main(args=None):
|
|||
help="Optional Text Message to display with a sound",
|
||||
)
|
||||
|
||||
# Trigger Message (without Sound) Alert
|
||||
# Trigger Message (without Sound) Alert
|
||||
parser.add_argument(
|
||||
"--silentmessage",
|
||||
action="store",
|
||||
|
@ -133,7 +125,7 @@ def main(args=None):
|
|||
help="Optional Text Message to display with no sounds",
|
||||
)
|
||||
|
||||
# Lost Mode
|
||||
# Lost Mode
|
||||
parser.add_argument(
|
||||
"--lostmode",
|
||||
action="store_true",
|
||||
|
@ -163,7 +155,7 @@ def main(args=None):
|
|||
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(
|
||||
"--outputfile",
|
||||
action="store_true",
|
||||
|
@ -185,55 +177,76 @@ def main(args=None):
|
|||
# Which password we use is determined by your username, so we
|
||||
# do need to check for this first and separately.
|
||||
if not username:
|
||||
parser.error('No username supplied')
|
||||
parser.error("No username supplied")
|
||||
|
||||
if not password:
|
||||
password = utils.get_password(
|
||||
username,
|
||||
interactive=command_line.interactive
|
||||
username, interactive=command_line.interactive
|
||||
)
|
||||
|
||||
if not password:
|
||||
parser.error('No password supplied')
|
||||
parser.error("No password supplied")
|
||||
|
||||
try:
|
||||
api = pyicloud.PyiCloudService(
|
||||
username.strip(),
|
||||
password.strip()
|
||||
)
|
||||
api = PyiCloudService(username.strip(), password.strip())
|
||||
if (
|
||||
not utils.password_exists_in_keyring(username) and
|
||||
command_line.interactive and
|
||||
confirm("Save password in keyring? ")
|
||||
not utils.password_exists_in_keyring(username)
|
||||
and command_line.interactive
|
||||
and confirm("Save password in keyring?")
|
||||
):
|
||||
utils.store_password_in_keyring(username, password)
|
||||
|
||||
if api.requires_2sa:
|
||||
import click
|
||||
print("Two-step authentication required.",
|
||||
"Your trusted devices are:")
|
||||
if api.requires_2fa:
|
||||
# fmt: off
|
||||
print(
|
||||
"\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
|
||||
for i, device in enumerate(devices):
|
||||
print(" %s: %s" % (
|
||||
i, device.get(
|
||||
'deviceName',
|
||||
"SMS to %s" % device.get('phoneNumber'))))
|
||||
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)
|
||||
print("\nWhich device would you like to use?")
|
||||
device = int(input("(number) --> "))
|
||||
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')
|
||||
print("\nPlease enter validation code")
|
||||
code = input("(string) --> ")
|
||||
if not api.validate_verification_code(device, code):
|
||||
print("Failed to verify verification code")
|
||||
sys.exit(1)
|
||||
|
||||
print("")
|
||||
break
|
||||
except pyicloud.exceptions.PyiCloudFailedLoginException:
|
||||
except PyiCloudFailedLoginException as err:
|
||||
# If they have a stored password; we just used it and
|
||||
# it did not work; let's delete it if there is one.
|
||||
if utils.password_exists_in_keyring(username):
|
||||
|
@ -246,38 +259,32 @@ def main(args=None):
|
|||
|
||||
failure_count += 1
|
||||
if failure_count >= 3:
|
||||
raise RuntimeError(message)
|
||||
raise RuntimeError(message) from err
|
||||
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
for dev in api.devices:
|
||||
if (
|
||||
not command_line.device_id or
|
||||
(
|
||||
command_line.device_id.strip().lower() ==
|
||||
dev.content["id"].strip().lower()
|
||||
)
|
||||
if not command_line.device_id or (
|
||||
command_line.device_id.strip().lower() == dev.content["id"].strip().lower()
|
||||
):
|
||||
# List device(s)
|
||||
# List device(s)
|
||||
if command_line.locate:
|
||||
dev.location()
|
||||
|
||||
if command_line.output_to_file:
|
||||
create_pickled_data(
|
||||
dev,
|
||||
filename=(
|
||||
dev.content["name"].strip().lower() + ".fmip_snapshot"
|
||||
)
|
||||
filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"),
|
||||
)
|
||||
|
||||
contents = dev.content
|
||||
if command_line.longlist:
|
||||
print("-"*30)
|
||||
print("-" * 30)
|
||||
print(contents["name"])
|
||||
for x in contents:
|
||||
print("%20s - %s" % (x, contents[x]))
|
||||
for key in contents:
|
||||
print("%20s - %s" % (key, contents[key]))
|
||||
elif command_line.list:
|
||||
print("-"*30)
|
||||
print("-" * 30)
|
||||
print("Name - %s" % contents["name"])
|
||||
print("Display Name - %s" % contents["deviceDisplayName"])
|
||||
print("Location - %s" % contents["location"])
|
||||
|
@ -286,68 +293,70 @@ def main(args=None):
|
|||
print("Device Class - %s" % contents["deviceClass"])
|
||||
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.device_id:
|
||||
dev.play_sound()
|
||||
else:
|
||||
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.",
|
||||
DEVICE_ERROR
|
||||
DEVICE_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
# Display a Message on the device
|
||||
# Display a Message on the device
|
||||
if command_line.message:
|
||||
if command_line.device_id:
|
||||
dev.display_message(
|
||||
subject='A Message',
|
||||
message=command_line.message,
|
||||
sounds=True
|
||||
subject="A Message", message=command_line.message, sounds=True
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"%s %s" % (
|
||||
"Messages can only be played "
|
||||
"on a singular device.",
|
||||
DEVICE_ERROR
|
||||
"%s %s"
|
||||
% (
|
||||
"Messages can only be played on a singular device.",
|
||||
DEVICE_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
# Display a Silent Message on the device
|
||||
# Display a Silent Message on the device
|
||||
if command_line.silentmessage:
|
||||
if command_line.device_id:
|
||||
dev.display_message(
|
||||
subject='A Silent Message',
|
||||
subject="A Silent Message",
|
||||
message=command_line.silentmessage,
|
||||
sounds=False
|
||||
sounds=False,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"%s %s" % (
|
||||
"%s %s"
|
||||
% (
|
||||
"Silent Messages can only be played "
|
||||
"on a singular device.",
|
||||
DEVICE_ERROR
|
||||
DEVICE_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
# Enable Lost mode
|
||||
# Enable Lost mode
|
||||
if command_line.lostmode:
|
||||
if command_line.device_id:
|
||||
dev.lost_device(
|
||||
number=command_line.lost_phone.strip(),
|
||||
text=command_line.lost_message.strip(),
|
||||
newpasscode=command_line.lost_password.strip()
|
||||
newpasscode=command_line.lost_password.strip(),
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"%s %s" % (
|
||||
"Lost Mode can only be activated "
|
||||
"on a singular device.",
|
||||
DEVICE_ERROR
|
||||
"%s %s"
|
||||
% (
|
||||
"Lost Mode can only be activated on a singular device.",
|
||||
DEVICE_ERROR,
|
||||
)
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
"""Library exceptions."""
|
||||
|
||||
|
||||
class PyiCloudException(Exception):
|
||||
"""Generic iCloud exception."""
|
||||
pass
|
||||
|
||||
|
||||
# API
|
||||
class PyiCloudAPIResponseException(PyiCloudException):
|
||||
"""iCloud response exception."""
|
||||
def __init__(self, reason, code=None, retry=False):
|
||||
self.reason = reason
|
||||
self.code = code
|
||||
|
@ -14,28 +18,33 @@ class PyiCloudAPIResponseException(PyiCloudException):
|
|||
if retry:
|
||||
message += ". Retrying ..."
|
||||
|
||||
super(PyiCloudAPIResponseException, self).__init__(message)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException):
|
||||
"""iCloud service not activated exception."""
|
||||
pass
|
||||
|
||||
|
||||
# Login
|
||||
class PyiCloudFailedLoginException(PyiCloudException):
|
||||
"""iCloud failed login exception."""
|
||||
pass
|
||||
|
||||
|
||||
class PyiCloud2SARequiredException(PyiCloudException):
|
||||
"""iCloud 2SA required exception."""
|
||||
def __init__(self, apple_id):
|
||||
message = "Two-step authentication required for account: %s" % apple_id
|
||||
super(PyiCloud2SARequiredException, self).__init__(message)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PyiCloudNoStoredPasswordAvailableException(PyiCloudException):
|
||||
"""iCloud no stored password exception."""
|
||||
pass
|
||||
|
||||
|
||||
# Webservice specific
|
||||
class PyiCloudNoDevicesException(PyiCloudException):
|
||||
"""iCloud no device exception."""
|
||||
pass
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Services."""
|
||||
from pyicloud.services.calendar import CalendarService
|
||||
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
|
||||
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.photos import PhotosService
|
||||
from pyicloud.services.account import AccountService
|
||||
from pyicloud.services.drive import DriveService
|
||||
|
|
|
@ -1,55 +1,330 @@
|
|||
import sys
|
||||
|
||||
import six
|
||||
"""Account service."""
|
||||
from collections import OrderedDict
|
||||
|
||||
from pyicloud.utils import underscore_to_camelcase
|
||||
|
||||
|
||||
class AccountService(object):
|
||||
class AccountService:
|
||||
"""The 'Account' iCloud service."""
|
||||
|
||||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
self._service_root = service_root
|
||||
|
||||
self._devices = []
|
||||
self._family = []
|
||||
self._storage = None
|
||||
|
||||
self._acc_endpoint = '%s/setup/web/device' % self._service_root
|
||||
self._account_devices_url = '%s/getDevices' % self._acc_endpoint
|
||||
|
||||
req = self.session.get(self._account_devices_url, params=self.params)
|
||||
self.response = req.json()
|
||||
|
||||
for device_info in self.response['devices']:
|
||||
# device_id = device_info['udid']
|
||||
# self._devices[device_id] = AccountDevice(device_info)
|
||||
self._devices.append(AccountDevice(device_info))
|
||||
self._acc_endpoint = "%s/setup/web" % self._service_root
|
||||
self._acc_devices_url = "%s/device/getDevices" % self._acc_endpoint
|
||||
self._acc_family_details_url = "%s/family/getFamilyDetails" % self._acc_endpoint
|
||||
self._acc_family_member_photo_url = (
|
||||
"%s/family/getMemberPhoto" % self._acc_endpoint
|
||||
)
|
||||
self._acc_storage_url = "https://setup.icloud.com/setup/ws/1/storageUsageInfo"
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
@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
|
||||
class AccountDevice(dict):
|
||||
def __init__(self, device_info):
|
||||
super(AccountDevice, self).__init__(device_info)
|
||||
for member_info in response["familyMembers"]:
|
||||
self._family.append(
|
||||
FamilyMember(
|
||||
member_info,
|
||||
self.session,
|
||||
self.params,
|
||||
self._acc_family_member_photo_url,
|
||||
)
|
||||
)
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self[underscore_to_camelcase(name)]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
return self._family
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
"""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):
|
||||
return u"{display_name}: {name}".format(
|
||||
display_name=self.model_display_name,
|
||||
name=self.name,
|
||||
return "{{devices: {}, family: {}, storage: {} bytes free}}".format(
|
||||
len(self.devices),
|
||||
len(self.family),
|
||||
self.storage.usage.available_storage_in_bytes,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{display}>'.format(
|
||||
display=(
|
||||
six.text_type(self)
|
||||
if sys.version_info[0] >= 3 else
|
||||
six.text_type(self).encode('utf8', 'replace')
|
||||
)
|
||||
return f"<{type(self).__name__}: {self}>"
|
||||
|
||||
|
||||
class AccountDevice(dict):
|
||||
"""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
|
||||
from datetime import datetime, timedelta
|
||||
"""Calendar service."""
|
||||
from datetime import datetime
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
self._service_root = service_root
|
||||
self._calendar_endpoint = '%s/ca' % self._service_root
|
||||
self._calendar_refresh_url = '%s/events' % self._calendar_endpoint
|
||||
self._calendar_event_detail_url = '%s/eventdetail' % (
|
||||
self._calendar_endpoint,
|
||||
)
|
||||
self._calendars = '%s/startup' % self._calendar_endpoint
|
||||
self._calendar_endpoint = "%s/ca" % self._service_root
|
||||
self._calendar_refresh_url = "%s/events" % self._calendar_endpoint
|
||||
self._calendar_event_detail_url = f"{self._calendar_endpoint}/eventdetail"
|
||||
self._calendars = "%s/startup" % self._calendar_endpoint
|
||||
|
||||
self.response = {}
|
||||
|
||||
def get_event_detail(self, pguid, guid):
|
||||
"""
|
||||
|
@ -27,11 +27,11 @@ class CalendarService(object):
|
|||
(a calendar) and a guid (an event's ID).
|
||||
"""
|
||||
params = dict(self.params)
|
||||
params.update({'lang': 'en-us', 'usertz': get_localzone().zone})
|
||||
url = '%s/%s/%s' % (self._calendar_event_detail_url, pguid, guid)
|
||||
params.update({"lang": "en-us", "usertz": get_localzone_name()})
|
||||
url = f"{self._calendar_event_detail_url}/{pguid}/{guid}"
|
||||
req = self.session.get(url, params=params)
|
||||
self.response = req.json()
|
||||
return self.response['Event'][0]
|
||||
return self.response["Event"][0]
|
||||
|
||||
def refresh_client(self, from_dt=None, to_dt=None):
|
||||
"""
|
||||
|
@ -46,12 +46,14 @@ class CalendarService(object):
|
|||
if not to_dt:
|
||||
to_dt = datetime(today.year, today.month, last_day)
|
||||
params = dict(self.params)
|
||||
params.update({
|
||||
'lang': 'en-us',
|
||||
'usertz': get_localzone().zone,
|
||||
'startDate': from_dt.strftime('%Y-%m-%d'),
|
||||
'endDate': to_dt.strftime('%Y-%m-%d')
|
||||
})
|
||||
params.update(
|
||||
{
|
||||
"lang": "en-us",
|
||||
"usertz": get_localzone_name(),
|
||||
"startDate": from_dt.strftime("%Y-%m-%d"),
|
||||
"endDate": to_dt.strftime("%Y-%m-%d"),
|
||||
}
|
||||
)
|
||||
req = self.session.get(self._calendar_refresh_url, params=params)
|
||||
self.response = req.json()
|
||||
|
||||
|
@ -60,23 +62,25 @@ class CalendarService(object):
|
|||
Retrieves events for a given date range, by default, this month.
|
||||
"""
|
||||
self.refresh_client(from_dt, to_dt)
|
||||
return self.response['Event']
|
||||
return self.response.get("Event")
|
||||
|
||||
def calendars(self):
|
||||
"""
|
||||
Retrieves calendars for this month
|
||||
Retrieves calendars of this month.
|
||||
"""
|
||||
today = datetime.today()
|
||||
first_day, last_day = monthrange(today.year, today.month)
|
||||
from_dt = datetime(today.year, today.month, first_day)
|
||||
to_dt = datetime(today.year, today.month, last_day)
|
||||
params = dict(self.params)
|
||||
params.update({
|
||||
'lang': 'en-us',
|
||||
'usertz': get_localzone().zone,
|
||||
'startDate': from_dt.strftime('%Y-%m-%d'),
|
||||
'endDate': to_dt.strftime('%Y-%m-%d')
|
||||
})
|
||||
params.update(
|
||||
{
|
||||
"lang": "en-us",
|
||||
"usertz": get_localzone_name(),
|
||||
"startDate": from_dt.strftime("%Y-%m-%d"),
|
||||
"endDate": to_dt.strftime("%Y-%m-%d"),
|
||||
}
|
||||
)
|
||||
req = self.session.get(self._calendars, params=params)
|
||||
self.response = req.json()
|
||||
return self.response['Collection']
|
||||
return self.response["Collection"]
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
from __future__ import absolute_import
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from calendar import monthrange
|
||||
"""Contacts service."""
|
||||
|
||||
|
||||
class ContactsService(object):
|
||||
class ContactsService:
|
||||
"""
|
||||
The 'Contacts' iCloud service, connects to iCloud and returns contacts.
|
||||
"""
|
||||
|
@ -14,9 +10,12 @@ class ContactsService(object):
|
|||
self.session = session
|
||||
self.params = params
|
||||
self._service_root = service_root
|
||||
self._contacts_endpoint = '%s/co' % self._service_root
|
||||
self._contacts_refresh_url = '%s/startup' % self._contacts_endpoint
|
||||
self._contacts_changeset_url = '%s/changeset' % self._contacts_endpoint
|
||||
self._contacts_endpoint = "%s/co" % self._service_root
|
||||
self._contacts_refresh_url = "%s/startup" % 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):
|
||||
"""
|
||||
|
@ -24,15 +23,26 @@ class ContactsService(object):
|
|||
contacts data is up-to-date.
|
||||
"""
|
||||
params_contacts = dict(self.params)
|
||||
params_contacts.update({
|
||||
'clientVersion': '2.1',
|
||||
'locale': 'en_US',
|
||||
'order': 'last,first',
|
||||
})
|
||||
req = self.session.get(
|
||||
self._contacts_refresh_url,
|
||||
params=params_contacts
|
||||
params_contacts.update(
|
||||
{
|
||||
"clientVersion": "2.1",
|
||||
"locale": "en_US",
|
||||
"order": "last,first",
|
||||
}
|
||||
)
|
||||
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()
|
||||
|
||||
def all(self):
|
||||
|
@ -40,4 +50,4 @@ class ContactsService(object):
|
|||
Retrieves all contacts.
|
||||
"""
|
||||
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 sys
|
||||
|
||||
import six
|
||||
|
||||
from pyicloud.exceptions import PyiCloudNoDevicesException
|
||||
|
||||
|
||||
class FindMyiPhoneServiceManager(object):
|
||||
class FindMyiPhoneServiceManager:
|
||||
"""The 'Find my iPhone' iCloud service
|
||||
|
||||
This connects to iCloud and return phone data including the near-realtime
|
||||
latitude and longitude.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, service_root, session, params, with_family=False):
|
||||
|
@ -19,11 +16,11 @@ class FindMyiPhoneServiceManager(object):
|
|||
self.params = params
|
||||
self.with_family = with_family
|
||||
|
||||
fmip_endpoint = '%s/fmipservice/client/web' % service_root
|
||||
self._fmip_refresh_url = '%s/refreshClient' % fmip_endpoint
|
||||
self._fmip_sound_url = '%s/playSound' % fmip_endpoint
|
||||
self._fmip_message_url = '%s/sendMessage' % fmip_endpoint
|
||||
self._fmip_lost_url = '%s/lostDevice' % fmip_endpoint
|
||||
fmip_endpoint = "%s/fmipservice/client/web" % service_root
|
||||
self._fmip_refresh_url = "%s/refreshClient" % fmip_endpoint
|
||||
self._fmip_sound_url = "%s/playSound" % fmip_endpoint
|
||||
self._fmip_message_url = "%s/sendMessage" % fmip_endpoint
|
||||
self._fmip_lost_url = "%s/lostDevice" % fmip_endpoint
|
||||
|
||||
self._devices = {}
|
||||
self.refresh_client()
|
||||
|
@ -39,18 +36,19 @@ class FindMyiPhoneServiceManager(object):
|
|||
params=self.params,
|
||||
data=json.dumps(
|
||||
{
|
||||
'clientContext': {
|
||||
'fmly': self.with_family,
|
||||
'shouldLocate': True,
|
||||
'selectedDevice': 'all',
|
||||
"clientContext": {
|
||||
"fmly": self.with_family,
|
||||
"shouldLocate": True,
|
||||
"selectedDevice": "all",
|
||||
"deviceListVersion": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
self.response = req.json()
|
||||
|
||||
for device_info in self.response['content']:
|
||||
device_id = device_info['id']
|
||||
for device_info in self.response["content"]:
|
||||
device_id = device_info["id"]
|
||||
if device_id not in self._devices:
|
||||
self._devices[device_id] = AppleDevice(
|
||||
device_info,
|
||||
|
@ -69,33 +67,31 @@ class FindMyiPhoneServiceManager(object):
|
|||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, int):
|
||||
if six.PY3:
|
||||
key = list(self.keys())[key]
|
||||
else:
|
||||
key = self.keys()[key]
|
||||
key = list(self.keys())[key]
|
||||
return self._devices[key]
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self._devices, attr)
|
||||
|
||||
def __unicode__(self):
|
||||
return six.text_type(self._devices)
|
||||
|
||||
def __str__(self):
|
||||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
return f"{self._devices}"
|
||||
|
||||
def __repr__(self):
|
||||
return six.text_type(self)
|
||||
return f"{self}"
|
||||
|
||||
|
||||
class AppleDevice(object):
|
||||
class AppleDevice:
|
||||
"""Apple device."""
|
||||
|
||||
def __init__(
|
||||
self, content, session, params, manager,
|
||||
sound_url=None, lost_url=None, message_url=None
|
||||
self,
|
||||
content,
|
||||
session,
|
||||
params,
|
||||
manager,
|
||||
sound_url=None,
|
||||
lost_url=None,
|
||||
message_url=None,
|
||||
):
|
||||
self.content = content
|
||||
self.manager = manager
|
||||
|
@ -107,46 +103,43 @@ class AppleDevice(object):
|
|||
self.message_url = message_url
|
||||
|
||||
def update(self, data):
|
||||
"""Updates the device data."""
|
||||
self.content = data
|
||||
|
||||
def location(self):
|
||||
"""Updates the device location."""
|
||||
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.
|
||||
|
||||
This returns only a subset of possible properties.
|
||||
"""
|
||||
self.manager.refresh_client()
|
||||
fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name']
|
||||
fields = ["batteryLevel", "deviceDisplayName", "deviceStatus", "name"]
|
||||
fields += additional
|
||||
properties = {}
|
||||
for field in fields:
|
||||
properties[field] = self.content.get(field)
|
||||
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.
|
||||
|
||||
It's possible to pass a custom message by changing the `subject`.
|
||||
"""
|
||||
data = json.dumps({
|
||||
'device': self.content['id'],
|
||||
'subject': subject,
|
||||
'clientContext': {
|
||||
'fmly': True
|
||||
data = json.dumps(
|
||||
{
|
||||
"device": self.content["id"],
|
||||
"subject": subject,
|
||||
"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(
|
||||
self, subject='Find My iPhone Alert', message="This is a note",
|
||||
sounds=False
|
||||
self, subject="Find My iPhone Alert", message="This is a note", sounds=False
|
||||
):
|
||||
"""Send a request to the device to play a sound.
|
||||
|
||||
|
@ -154,23 +147,17 @@ class AppleDevice(object):
|
|||
"""
|
||||
data = json.dumps(
|
||||
{
|
||||
'device': self.content['id'],
|
||||
'subject': subject,
|
||||
'sound': sounds,
|
||||
'userText': True,
|
||||
'text': message
|
||||
"device": self.content["id"],
|
||||
"subject": subject,
|
||||
"sound": sounds,
|
||||
"userText": True,
|
||||
"text": message,
|
||||
}
|
||||
)
|
||||
self.session.post(
|
||||
self.message_url,
|
||||
params=self.params,
|
||||
data=data
|
||||
)
|
||||
self.session.post(self.message_url, params=self.params, data=data)
|
||||
|
||||
def lost_device(
|
||||
self, number,
|
||||
text='This iPhone has been lost. Please call me.',
|
||||
newpasscode=""
|
||||
self, number, text="This iPhone has been lost. Please call me.", newpasscode=""
|
||||
):
|
||||
"""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
|
||||
the number without entering the passcode.
|
||||
"""
|
||||
data = json.dumps({
|
||||
'text': text,
|
||||
'userText': True,
|
||||
'ownerNbr': number,
|
||||
'lostModeEnabled': True,
|
||||
'trackingEnabled': True,
|
||||
'device': self.content['id'],
|
||||
'passcode': newpasscode
|
||||
})
|
||||
self.session.post(
|
||||
self.lost_url,
|
||||
params=self.params,
|
||||
data=data
|
||||
data = json.dumps(
|
||||
{
|
||||
"text": text,
|
||||
"userText": True,
|
||||
"ownerNbr": number,
|
||||
"lostModeEnabled": True,
|
||||
"trackingEnabled": True,
|
||||
"device": self.content["id"],
|
||||
"passcode": newpasscode,
|
||||
}
|
||||
)
|
||||
self.session.post(self.lost_url, params=self.params, data=data)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""Gets the device data."""
|
||||
return self.content
|
||||
|
||||
def __getitem__(self, key):
|
||||
|
@ -203,20 +189,8 @@ class AppleDevice(object):
|
|||
def __getattr__(self, 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):
|
||||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
return f"{self['deviceDisplayName']}: {self['name']}"
|
||||
|
||||
def __repr__(self):
|
||||
return '<AppleDevice(%s)>' % str(self)
|
||||
return f"<AppleDevice({self})>"
|
||||
|
|
|
@ -1,134 +1,123 @@
|
|||
import sys
|
||||
"""Photo service."""
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
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."""
|
||||
|
||||
SMART_FOLDERS = {
|
||||
"All Photos": {
|
||||
"obj_type": "CPLAssetByAddedDate",
|
||||
"list_type": "CPLAssetAndMasterByAddedDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": None
|
||||
"query_filter": None,
|
||||
},
|
||||
"Time-lapse": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Timelapse",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "TIMELAPSE"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "TIMELAPSE"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Videos": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Video",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "VIDEO"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "VIDEO"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Slo-mo": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "SLOMO"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "SLOMO"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Bursts": {
|
||||
"obj_type": "CPLAssetBurstStackAssetByAssetDate",
|
||||
"list_type": "CPLBurstStackAssetAndMasterByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": None
|
||||
"query_filter": None,
|
||||
},
|
||||
"Favorites": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Favorite",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "FAVORITE"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "FAVORITE"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Panoramas": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "PANORAMA"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "PANORAMA"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Screenshots": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "SCREENSHOT"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "SCREENSHOT"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Live": {
|
||||
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Live",
|
||||
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": [{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {
|
||||
"type": "STRING",
|
||||
"value": "LIVE"
|
||||
"query_filter": [
|
||||
{
|
||||
"fieldName": "smartAlbum",
|
||||
"comparator": "EQUALS",
|
||||
"fieldValue": {"type": "STRING", "value": "LIVE"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
},
|
||||
"Recently Deleted": {
|
||||
"obj_type": "CPLAssetDeletedByExpungedDate",
|
||||
"list_type": "CPLAssetAndMasterDeletedByExpungedDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": None
|
||||
"query_filter": None,
|
||||
},
|
||||
"Hidden": {
|
||||
"obj_type": "CPLAssetHiddenByAssetDate",
|
||||
"list_type": "CPLAssetAndMasterHiddenByAssetDate",
|
||||
"direction": "ASCENDING",
|
||||
"query_filter": None
|
||||
"query_filter": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -136,35 +125,32 @@ class PhotosService(object):
|
|||
self.session = session
|
||||
self.params = dict(params)
|
||||
self._service_root = service_root
|
||||
self._service_endpoint = \
|
||||
('%s/database/1/com.apple.photos.cloud/production/private'
|
||||
% self._service_root)
|
||||
self.service_endpoint = (
|
||||
"%s/database/1/com.apple.photos.cloud/production/private"
|
||||
% self._service_root
|
||||
)
|
||||
|
||||
self._albums = None
|
||||
|
||||
self.params.update({
|
||||
'remapEnums': True,
|
||||
'getCurrentSyncToken': True
|
||||
})
|
||||
self.params.update({"remapEnums": True, "getCurrentSyncToken": True})
|
||||
|
||||
url = ('%s/records/query?%s' %
|
||||
(self._service_endpoint, urlencode(self.params)))
|
||||
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
||||
url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
|
||||
json_data = (
|
||||
'{"query":{"recordType":"CheckIndexingState"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}'
|
||||
)
|
||||
request = self.session.post(
|
||||
url,
|
||||
data=json_data,
|
||||
headers={'Content-type': 'text/plain'}
|
||||
url, data=json_data, headers={"Content-type": "text/plain"}
|
||||
)
|
||||
response = request.json()
|
||||
indexing_state = response['records'][0]['fields']['state']['value']
|
||||
if indexing_state != 'FINISHED':
|
||||
indexing_state = response["records"][0]["fields"]["state"]["value"]
|
||||
if indexing_state != "FINISHED":
|
||||
raise PyiCloudServiceNotActivatedException(
|
||||
'iCloud Photo Library not finished indexing. '
|
||||
'Please try again in a few minutes.'
|
||||
"iCloud Photo Library not finished indexing. "
|
||||
"Please try again in a few minutes."
|
||||
)
|
||||
|
||||
# TODO: Does syncToken ever change?
|
||||
# TODO: Does syncToken ever change? # pylint: disable=fixme
|
||||
# self.params.update({
|
||||
# 'syncToken': response['syncToken'],
|
||||
# 'clientInstanceId': self.params.pop('clientId')
|
||||
|
@ -174,62 +160,86 @@ class PhotosService(object):
|
|||
|
||||
@property
|
||||
def albums(self):
|
||||
"""Returns photo albums."""
|
||||
if not self._albums:
|
||||
self._albums = {name: PhotoAlbum(self, name, **props)
|
||||
for (name, props) in self.SMART_FOLDERS.items()}
|
||||
self._albums = {
|
||||
name: PhotoAlbum(self, name, **props)
|
||||
for (name, props) in self.SMART_FOLDERS.items()
|
||||
}
|
||||
|
||||
for folder in self._fetch_folders():
|
||||
# FIXME: Handle subfolders
|
||||
if folder['recordName'] == '----Root-Folder----' or \
|
||||
(folder['fields'].get('isDeleted') and
|
||||
folder['fields']['isDeleted']['value']):
|
||||
|
||||
# Skiping albums having null name, that can happen sometime
|
||||
if "albumNameEnc" not in folder["fields"]:
|
||||
continue
|
||||
|
||||
folder_id = folder['recordName']
|
||||
folder_obj_type = \
|
||||
"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
|
||||
}
|
||||
}]
|
||||
# TODO: Handle subfolders # pylint: disable=fixme
|
||||
if folder["recordName"] == "----Root-Folder----" or (
|
||||
folder["fields"].get("isDeleted")
|
||||
and folder["fields"]["isDeleted"]["value"]
|
||||
):
|
||||
continue
|
||||
|
||||
album = PhotoAlbum(self, folder_name,
|
||||
'CPLContainerRelationLiveByAssetDate',
|
||||
folder_obj_type, 'ASCENDING', query_filter)
|
||||
folder_id = folder["recordName"]
|
||||
folder_obj_type = (
|
||||
"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
|
||||
|
||||
return self._albums
|
||||
|
||||
def _fetch_folders(self):
|
||||
url = ('%s/records/query?%s' %
|
||||
(self._service_endpoint, urlencode(self.params)))
|
||||
json_data = ('{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
||||
url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
|
||||
json_data = (
|
||||
'{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}'
|
||||
)
|
||||
|
||||
request = self.session.post(
|
||||
url,
|
||||
data=json_data,
|
||||
headers={'Content-type': 'text/plain'}
|
||||
url, data=json_data, headers={"Content-type": "text/plain"}
|
||||
)
|
||||
response = request.json()
|
||||
|
||||
return response['records']
|
||||
return response["records"]
|
||||
|
||||
@property
|
||||
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,
|
||||
query_filter=None, page_size=100):
|
||||
def __init__(
|
||||
self,
|
||||
service,
|
||||
name,
|
||||
list_type,
|
||||
obj_type,
|
||||
direction,
|
||||
query_filter=None,
|
||||
page_size=100,
|
||||
):
|
||||
self.name = name
|
||||
self.service = service
|
||||
self.list_type = list_type
|
||||
|
@ -242,6 +252,7 @@ class PhotoAlbum(object):
|
|||
|
||||
@property
|
||||
def title(self):
|
||||
"""Gets the album name."""
|
||||
return self.name
|
||||
|
||||
def __iter__(self):
|
||||
|
@ -249,48 +260,74 @@ class PhotoAlbum(object):
|
|||
|
||||
def __len__(self):
|
||||
if self._len is None:
|
||||
url = ('%s/internal/records/query/batch?%s' %
|
||||
(self.service._service_endpoint,
|
||||
urlencode(self.service.params)))
|
||||
url = "{}/internal/records/query/batch?{}".format(
|
||||
self.service.service_endpoint,
|
||||
urlencode(self.service.params),
|
||||
)
|
||||
request = self.service.session.post(
|
||||
url,
|
||||
data=json.dumps(self._count_query_gen(self.obj_type)),
|
||||
headers={'Content-type': 'text/plain'}
|
||||
data=json.dumps(
|
||||
{
|
||||
"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()
|
||||
|
||||
self._len = (response["batch"][0]["records"][0]["fields"]
|
||||
["itemCount"]["value"])
|
||||
self._len = response["batch"][0]["records"][0]["fields"]["itemCount"][
|
||||
"value"
|
||||
]
|
||||
|
||||
return self._len
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
"""Returns the album photos."""
|
||||
if self.direction == "DESCENDING":
|
||||
offset = len(self) - 1
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
while(True):
|
||||
url = ('%s/records/query?' % self.service._service_endpoint) + \
|
||||
urlencode(self.service.params)
|
||||
while True:
|
||||
url = ("%s/records/query?" % self.service.service_endpoint) + urlencode(
|
||||
self.service.params
|
||||
)
|
||||
request = self.service.session.post(
|
||||
url,
|
||||
data=json.dumps(self._list_query_gen(
|
||||
offset, self.list_type, self.direction,
|
||||
self.query_filter)),
|
||||
headers={'Content-type': 'text/plain'}
|
||||
data=json.dumps(
|
||||
self._list_query_gen(
|
||||
offset, self.list_type, self.direction, self.query_filter
|
||||
)
|
||||
),
|
||||
headers={"Content-type": "text/plain"},
|
||||
)
|
||||
response = request.json()
|
||||
|
||||
asset_records = {}
|
||||
master_records = []
|
||||
for rec in response['records']:
|
||||
if rec['recordType'] == "CPLAsset":
|
||||
master_id = \
|
||||
rec['fields']['masterRef']['value']['recordName']
|
||||
for rec in response["records"]:
|
||||
if rec["recordType"] == "CPLAsset":
|
||||
master_id = rec["fields"]["masterRef"]["value"]["recordName"]
|
||||
asset_records[master_id] = rec
|
||||
elif rec['recordType'] == "CPLMaster":
|
||||
elif rec["recordType"] == "CPLMaster":
|
||||
master_records.append(rec)
|
||||
|
||||
master_records_len = len(master_records)
|
||||
|
@ -301,119 +338,148 @@ class PhotoAlbum(object):
|
|||
offset = offset + master_records_len
|
||||
|
||||
for master_record in master_records:
|
||||
record_name = master_record['recordName']
|
||||
yield PhotoAsset(self.service, master_record,
|
||||
asset_records[record_name])
|
||||
record_name = master_record["recordName"]
|
||||
yield PhotoAsset(
|
||||
self.service, master_record, asset_records[record_name]
|
||||
)
|
||||
else:
|
||||
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):
|
||||
query = {
|
||||
u'query': {
|
||||
u'filterBy': [
|
||||
{u'fieldName': u'startRank', u'fieldValue':
|
||||
{u'type': u'INT64', u'value': offset},
|
||||
u'comparator': u'EQUALS'},
|
||||
{u'fieldName': u'direction', u'fieldValue':
|
||||
{u'type': u'STRING', u'value': direction},
|
||||
u'comparator': u'EQUALS'}
|
||||
"query": {
|
||||
"filterBy": [
|
||||
{
|
||||
"fieldName": "startRank",
|
||||
"fieldValue": {"type": "INT64", "value": offset},
|
||||
"comparator": "EQUALS",
|
||||
},
|
||||
{
|
||||
"fieldName": "direction",
|
||||
"fieldValue": {"type": "STRING", "value": direction},
|
||||
"comparator": "EQUALS",
|
||||
},
|
||||
],
|
||||
u'recordType': list_type
|
||||
"recordType": list_type,
|
||||
},
|
||||
u'resultsLimit': self.page_size * 2,
|
||||
u'desiredKeys': [
|
||||
u'resJPEGFullWidth', u'resJPEGFullHeight',
|
||||
u'resJPEGFullFileType', u'resJPEGFullFingerprint',
|
||||
u'resJPEGFullRes', u'resJPEGLargeWidth',
|
||||
u'resJPEGLargeHeight', u'resJPEGLargeFileType',
|
||||
u'resJPEGLargeFingerprint', u'resJPEGLargeRes',
|
||||
u'resJPEGMedWidth', u'resJPEGMedHeight',
|
||||
u'resJPEGMedFileType', u'resJPEGMedFingerprint',
|
||||
u'resJPEGMedRes', u'resJPEGThumbWidth',
|
||||
u'resJPEGThumbHeight', u'resJPEGThumbFileType',
|
||||
u'resJPEGThumbFingerprint', u'resJPEGThumbRes',
|
||||
u'resVidFullWidth', u'resVidFullHeight',
|
||||
u'resVidFullFileType', u'resVidFullFingerprint',
|
||||
u'resVidFullRes', u'resVidMedWidth', u'resVidMedHeight',
|
||||
u'resVidMedFileType', u'resVidMedFingerprint',
|
||||
u'resVidMedRes', u'resVidSmallWidth', u'resVidSmallHeight',
|
||||
u'resVidSmallFileType', u'resVidSmallFingerprint',
|
||||
u'resVidSmallRes', u'resSidecarWidth', u'resSidecarHeight',
|
||||
u'resSidecarFileType', u'resSidecarFingerprint',
|
||||
u'resSidecarRes', u'itemType', u'dataClassType',
|
||||
u'filenameEnc', u'originalOrientation', u'resOriginalWidth',
|
||||
u'resOriginalHeight', u'resOriginalFileType',
|
||||
u'resOriginalFingerprint', u'resOriginalRes',
|
||||
u'resOriginalAltWidth', u'resOriginalAltHeight',
|
||||
u'resOriginalAltFileType', u'resOriginalAltFingerprint',
|
||||
u'resOriginalAltRes', u'resOriginalVidComplWidth',
|
||||
u'resOriginalVidComplHeight', u'resOriginalVidComplFileType',
|
||||
u'resOriginalVidComplFingerprint', u'resOriginalVidComplRes',
|
||||
u'isDeleted', u'isExpunged', u'dateExpunged', u'remappedRef',
|
||||
u'recordName', u'recordType', u'recordChangeTag',
|
||||
u'masterRef', u'adjustmentRenderType', u'assetDate',
|
||||
u'addedDate', u'isFavorite', u'isHidden', u'orientation',
|
||||
u'duration', u'assetSubtype', u'assetSubtypeV2',
|
||||
u'assetHDRType', u'burstFlags', u'burstFlagsExt', u'burstId',
|
||||
u'captionEnc', u'locationEnc', u'locationV2Enc',
|
||||
u'locationLatitude', u'locationLongitude', u'adjustmentType',
|
||||
u'timeZoneOffset', u'vidComplDurValue', u'vidComplDurScale',
|
||||
u'vidComplDispValue', u'vidComplDispScale',
|
||||
u'vidComplVisibilityState', u'customRenderedValue',
|
||||
u'containerId', u'itemId', u'position', u'isKeyAsset'
|
||||
"resultsLimit": self.page_size * 2,
|
||||
"desiredKeys": [
|
||||
"resJPEGFullWidth",
|
||||
"resJPEGFullHeight",
|
||||
"resJPEGFullFileType",
|
||||
"resJPEGFullFingerprint",
|
||||
"resJPEGFullRes",
|
||||
"resJPEGLargeWidth",
|
||||
"resJPEGLargeHeight",
|
||||
"resJPEGLargeFileType",
|
||||
"resJPEGLargeFingerprint",
|
||||
"resJPEGLargeRes",
|
||||
"resJPEGMedWidth",
|
||||
"resJPEGMedHeight",
|
||||
"resJPEGMedFileType",
|
||||
"resJPEGMedFingerprint",
|
||||
"resJPEGMedRes",
|
||||
"resJPEGThumbWidth",
|
||||
"resJPEGThumbHeight",
|
||||
"resJPEGThumbFileType",
|
||||
"resJPEGThumbFingerprint",
|
||||
"resJPEGThumbRes",
|
||||
"resVidFullWidth",
|
||||
"resVidFullHeight",
|
||||
"resVidFullFileType",
|
||||
"resVidFullFingerprint",
|
||||
"resVidFullRes",
|
||||
"resVidMedWidth",
|
||||
"resVidMedHeight",
|
||||
"resVidMedFileType",
|
||||
"resVidMedFingerprint",
|
||||
"resVidMedRes",
|
||||
"resVidSmallWidth",
|
||||
"resVidSmallHeight",
|
||||
"resVidSmallFileType",
|
||||
"resVidSmallFingerprint",
|
||||
"resVidSmallRes",
|
||||
"resSidecarWidth",
|
||||
"resSidecarHeight",
|
||||
"resSidecarFileType",
|
||||
"resSidecarFingerprint",
|
||||
"resSidecarRes",
|
||||
"itemType",
|
||||
"dataClassType",
|
||||
"filenameEnc",
|
||||
"originalOrientation",
|
||||
"resOriginalWidth",
|
||||
"resOriginalHeight",
|
||||
"resOriginalFileType",
|
||||
"resOriginalFingerprint",
|
||||
"resOriginalRes",
|
||||
"resOriginalAltWidth",
|
||||
"resOriginalAltHeight",
|
||||
"resOriginalAltFileType",
|
||||
"resOriginalAltFingerprint",
|
||||
"resOriginalAltRes",
|
||||
"resOriginalVidComplWidth",
|
||||
"resOriginalVidComplHeight",
|
||||
"resOriginalVidComplFileType",
|
||||
"resOriginalVidComplFingerprint",
|
||||
"resOriginalVidComplRes",
|
||||
"isDeleted",
|
||||
"isExpunged",
|
||||
"dateExpunged",
|
||||
"remappedRef",
|
||||
"recordName",
|
||||
"recordType",
|
||||
"recordChangeTag",
|
||||
"masterRef",
|
||||
"adjustmentRenderType",
|
||||
"assetDate",
|
||||
"addedDate",
|
||||
"isFavorite",
|
||||
"isHidden",
|
||||
"orientation",
|
||||
"duration",
|
||||
"assetSubtype",
|
||||
"assetSubtypeV2",
|
||||
"assetHDRType",
|
||||
"burstFlags",
|
||||
"burstFlagsExt",
|
||||
"burstId",
|
||||
"captionEnc",
|
||||
"locationEnc",
|
||||
"locationV2Enc",
|
||||
"locationLatitude",
|
||||
"locationLongitude",
|
||||
"adjustmentType",
|
||||
"timeZoneOffset",
|
||||
"vidComplDurValue",
|
||||
"vidComplDurScale",
|
||||
"vidComplDispValue",
|
||||
"vidComplDispScale",
|
||||
"vidComplVisibilityState",
|
||||
"customRenderedValue",
|
||||
"containerId",
|
||||
"itemId",
|
||||
"position",
|
||||
"isKeyAsset",
|
||||
],
|
||||
u'zoneID': {u'zoneName': u'PrimarySync'}
|
||||
"zoneID": {"zoneName": "PrimarySync"},
|
||||
}
|
||||
|
||||
if query_filter:
|
||||
query['query']['filterBy'].extend(query_filter)
|
||||
query["query"]["filterBy"].extend(query_filter)
|
||||
|
||||
return query
|
||||
|
||||
def __unicode__(self):
|
||||
def __str__(self):
|
||||
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):
|
||||
return "<%s: '%s'>" % (
|
||||
type(self).__name__,
|
||||
self
|
||||
)
|
||||
return f"<{type(self).__name__}: '{self}'>"
|
||||
|
||||
|
||||
class PhotoAsset(object):
|
||||
class PhotoAsset:
|
||||
"""A photo."""
|
||||
|
||||
def __init__(self, service, master_record, asset_record):
|
||||
self._service = service
|
||||
self._master_record = master_record
|
||||
|
@ -422,91 +488,151 @@ class PhotoAsset(object):
|
|||
self._versions = None
|
||||
|
||||
PHOTO_VERSION_LOOKUP = {
|
||||
u"original": u"resOriginal",
|
||||
u"medium": u"resJPEGMed",
|
||||
u"thumb": u"resJPEGThumb"
|
||||
"original": "resOriginal",
|
||||
"medium": "resJPEGMed",
|
||||
"thumb": "resJPEGThumb",
|
||||
}
|
||||
|
||||
VIDEO_VERSION_LOOKUP = {
|
||||
u"original": u"resOriginal",
|
||||
u"medium": u"resVidMed",
|
||||
u"thumb": u"resVidSmall"
|
||||
"original": "resOriginal",
|
||||
"medium": "resVidMed",
|
||||
"thumb": "resVidSmall",
|
||||
}
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._master_record['recordName']
|
||||
"""Gets the photo id."""
|
||||
return self._master_record["recordName"]
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""Gets the photo file name."""
|
||||
return base64.b64decode(
|
||||
self._master_record['fields']['filenameEnc']['value']
|
||||
).decode('utf-8')
|
||||
self._master_record["fields"]["filenameEnc"]["value"]
|
||||
).decode("utf-8")
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._master_record['fields']['resOriginalRes']['value']['size']
|
||||
"""Gets the photo size."""
|
||||
return self._master_record["fields"]["resOriginalRes"]["value"]["size"]
|
||||
|
||||
@property
|
||||
def created(self):
|
||||
"""Gets the photo created date."""
|
||||
return self.asset_date
|
||||
|
||||
@property
|
||||
def asset_date(self):
|
||||
"""Gets the photo asset date."""
|
||||
try:
|
||||
dt = datetime.fromtimestamp(
|
||||
self._asset_record['fields']['assetDate']['value'] / 1000.0,
|
||||
tz=pytz.utc)
|
||||
except:
|
||||
dt = datetime.fromtimestamp(0)
|
||||
return dt
|
||||
return datetime.utcfromtimestamp(
|
||||
self._asset_record["fields"]["assetDate"]["value"] / 1000.0
|
||||
).replace(tzinfo=timezone.utc)
|
||||
except KeyError:
|
||||
return datetime.utcfromtimestamp(0).replace(tzinfo=timezone.utc)
|
||||
|
||||
@property
|
||||
def added_date(self):
|
||||
dt = datetime.fromtimestamp(
|
||||
self._asset_record['fields']['addedDate']['value'] / 1000.0,
|
||||
tz=pytz.utc)
|
||||
return dt
|
||||
"""Gets the photo added date."""
|
||||
return datetime.utcfromtimestamp(
|
||||
self._asset_record["fields"]["addedDate"]["value"] / 1000.0
|
||||
).replace(tzinfo=timezone.utc)
|
||||
|
||||
@property
|
||||
def dimensions(self):
|
||||
return (self._master_record['fields']['resOriginalWidth']['value'],
|
||||
self._master_record['fields']['resOriginalHeight']['value'])
|
||||
"""Gets the photo dimensions."""
|
||||
return (
|
||||
self._master_record["fields"]["resOriginalWidth"]["value"],
|
||||
self._master_record["fields"]["resOriginalHeight"]["value"],
|
||||
)
|
||||
|
||||
@property
|
||||
def versions(self):
|
||||
"""Gets the photo versions."""
|
||||
if not 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
|
||||
else:
|
||||
typed_version_lookup = self.PHOTO_VERSION_LOOKUP
|
||||
|
||||
for key, prefix in typed_version_lookup.items():
|
||||
if '%sWidth' % prefix in self._master_record['fields']:
|
||||
f = self._master_record['fields']
|
||||
self._versions[key] = {
|
||||
'width': f['%sWidth' % prefix]['value'],
|
||||
'height': f['%sHeight' % prefix]['value'],
|
||||
'size': f['%sRes' % prefix]['value']['size'],
|
||||
'type': f['%sFileType' % prefix]['value'],
|
||||
'url': f['%sRes' % prefix]['value']['downloadURL'],
|
||||
'filename': self.filename,
|
||||
}
|
||||
if "%sRes" % prefix in self._master_record["fields"]:
|
||||
fields = self._master_record["fields"]
|
||||
version = {"filename": self.filename}
|
||||
|
||||
width_entry = fields.get("%sWidth" % prefix)
|
||||
if width_entry:
|
||||
version["width"] = width_entry["value"]
|
||||
else:
|
||||
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
|
||||
|
||||
def download(self, version='original', **kwargs):
|
||||
def download(self, version="original", **kwargs):
|
||||
"""Returns the photo file."""
|
||||
if version not in self.versions:
|
||||
return None
|
||||
|
||||
return self._service.session.get(
|
||||
self.versions[version]['url'],
|
||||
stream=True,
|
||||
**kwargs
|
||||
self.versions[version]["url"], 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):
|
||||
return "<%s: id=%s>" % (
|
||||
type(self).__name__,
|
||||
self.id
|
||||
)
|
||||
return f"<{type(self).__name__}: id={self.id}>"
|
||||
|
|
|
@ -1,121 +1,123 @@
|
|||
from __future__ import absolute_import
|
||||
from datetime import datetime, timedelta
|
||||
"""Reminders service."""
|
||||
from datetime import datetime
|
||||
import time
|
||||
import uuid
|
||||
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):
|
||||
self.session = session
|
||||
self.params = params
|
||||
self._params = params
|
||||
self._service_root = service_root
|
||||
|
||||
self.lists = {}
|
||||
self.collections = {}
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
params_reminders = dict(self.params)
|
||||
params_reminders.update({
|
||||
'clientVersion': '4.0',
|
||||
'lang': 'en-us',
|
||||
'usertz': get_localzone().zone
|
||||
})
|
||||
"""Refresh data."""
|
||||
params_reminders = dict(self._params)
|
||||
params_reminders.update(
|
||||
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
|
||||
)
|
||||
|
||||
# Open reminders
|
||||
req = self.session.get(
|
||||
self._service_root + '/rd/startup',
|
||||
params=params_reminders
|
||||
self._service_root + "/rd/startup", params=params_reminders
|
||||
)
|
||||
|
||||
startup = req.json()
|
||||
data = req.json()
|
||||
|
||||
self.lists = {}
|
||||
self.collections = {}
|
||||
for collection in startup['Collections']:
|
||||
for collection in data["Collections"]:
|
||||
temp = []
|
||||
self.collections[collection['title']] = {
|
||||
'guid': collection['guid'],
|
||||
'ctag': collection['ctag']
|
||||
self.collections[collection["title"]] = {
|
||||
"guid": collection["guid"],
|
||||
"ctag": collection["ctag"],
|
||||
}
|
||||
for reminder in startup['Reminders']:
|
||||
for reminder in data["Reminders"]:
|
||||
|
||||
if reminder['pGuid'] != collection['guid']:
|
||||
if reminder["pGuid"] != collection["guid"]:
|
||||
continue
|
||||
if 'dueDate' in reminder:
|
||||
if reminder['dueDate']:
|
||||
due = datetime(
|
||||
reminder['dueDate'][1],
|
||||
reminder['dueDate'][2], reminder['dueDate'][3],
|
||||
reminder['dueDate'][4], reminder['dueDate'][5]
|
||||
)
|
||||
else:
|
||||
due = None
|
||||
|
||||
if reminder.get("dueDate"):
|
||||
due = datetime(
|
||||
reminder["dueDate"][1],
|
||||
reminder["dueDate"][2],
|
||||
reminder["dueDate"][3],
|
||||
reminder["dueDate"][4],
|
||||
reminder["dueDate"][5],
|
||||
)
|
||||
else:
|
||||
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):
|
||||
pguid = 'tasks'
|
||||
temp.append(
|
||||
{
|
||||
"title": reminder["title"],
|
||||
"desc": reminder.get("description"),
|
||||
"due": due,
|
||||
}
|
||||
)
|
||||
self.lists[collection["title"]] = temp
|
||||
|
||||
def post(self, title, description="", collection=None, due_date=None):
|
||||
"""Adds a new reminder."""
|
||||
pguid = "tasks"
|
||||
if collection:
|
||||
if collection in self.collections:
|
||||
pguid = self.collections[collection]['guid']
|
||||
pguid = self.collections[collection]["guid"]
|
||||
|
||||
params_reminders = dict(self.params)
|
||||
params_reminders.update({
|
||||
'clientVersion': '4.0',
|
||||
'lang': 'en-us',
|
||||
'usertz': get_localzone().zone
|
||||
})
|
||||
params_reminders = dict(self._params)
|
||||
params_reminders.update(
|
||||
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
|
||||
)
|
||||
|
||||
dueDateList = None
|
||||
if dueDate:
|
||||
dueDateList = [
|
||||
int(str(dueDate.year) + str(dueDate.month) + str(dueDate.day)),
|
||||
dueDate.year,
|
||||
dueDate.month,
|
||||
dueDate.day,
|
||||
dueDate.hour,
|
||||
dueDate.minute
|
||||
due_dates = None
|
||||
if due_date:
|
||||
due_dates = [
|
||||
int(str(due_date.year) + str(due_date.month) + str(due_date.day)),
|
||||
due_date.year,
|
||||
due_date.month,
|
||||
due_date.day,
|
||||
due_date.hour,
|
||||
due_date.minute,
|
||||
]
|
||||
|
||||
req = self.session.post(
|
||||
self._service_root + '/rd/reminders/tasks',
|
||||
data=json.dumps({
|
||||
"Reminders": {
|
||||
'title': title,
|
||||
"description": description,
|
||||
"pGuid": pguid,
|
||||
"etag": None,
|
||||
"order": None,
|
||||
"priority": 0,
|
||||
"recurrence": None,
|
||||
"alarms": [],
|
||||
"startDate": None,
|
||||
"startDateTz": None,
|
||||
"startDateIsAllDay": False,
|
||||
"completedDate": None,
|
||||
"dueDate": dueDateList,
|
||||
"dueDateIsAllDay": False,
|
||||
"lastModifiedDate": None,
|
||||
"createdDate": None,
|
||||
"isFamily": None,
|
||||
"createdDateExtended": int(time.time()*1000),
|
||||
"guid": str(uuid.uuid4())
|
||||
},
|
||||
"ClientState": {"Collections": list(self.collections.values())}
|
||||
}),
|
||||
params=params_reminders)
|
||||
self._service_root + "/rd/reminders/tasks",
|
||||
data=json.dumps(
|
||||
{
|
||||
"Reminders": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"pGuid": pguid,
|
||||
"etag": None,
|
||||
"order": None,
|
||||
"priority": 0,
|
||||
"recurrence": None,
|
||||
"alarms": [],
|
||||
"startDate": None,
|
||||
"startDateTz": None,
|
||||
"startDateIsAllDay": False,
|
||||
"completedDate": None,
|
||||
"dueDate": due_dates,
|
||||
"dueDateIsAllDay": False,
|
||||
"lastModifiedDate": None,
|
||||
"createdDate": None,
|
||||
"isFamily": None,
|
||||
"createdDateExtended": int(time.time() * 1000),
|
||||
"guid": str(uuid.uuid4()),
|
||||
},
|
||||
"ClientState": {"Collections": list(self.collections.values())},
|
||||
}
|
||||
),
|
||||
params=params_reminders,
|
||||
)
|
||||
return req.ok
|
||||
|
|
|
@ -1,49 +1,43 @@
|
|||
"""File service."""
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
|
||||
class UbiquityService(object):
|
||||
class UbiquityService:
|
||||
"""The 'Ubiquity' iCloud service."""
|
||||
|
||||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
|
||||
self._root = None
|
||||
|
||||
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
|
||||
self._node_url = service_root + "/ws/%s/%s/%s"
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""Gets the root node."""
|
||||
if not self._root:
|
||||
self._root = self.get_node(0)
|
||||
return self._root
|
||||
|
||||
def get_node_url(self, node_id, variant="item"):
|
||||
"""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):
|
||||
return getattr(self.root, attr)
|
||||
|
||||
|
@ -51,71 +45,69 @@ class UbiquityService(object):
|
|||
return self.root[key]
|
||||
|
||||
|
||||
class UbiquityNode(object):
|
||||
class UbiquityNode:
|
||||
"""Ubiquity node."""
|
||||
|
||||
def __init__(self, conn, data):
|
||||
self.data = data
|
||||
self.connection = conn
|
||||
|
||||
self._children = None
|
||||
|
||||
@property
|
||||
def item_id(self):
|
||||
return self.data.get('item_id')
|
||||
"""Gets the node id."""
|
||||
return self.data.get("item_id")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.data.get('name')
|
||||
"""Gets the node name."""
|
||||
return self.data.get("name")
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
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
|
||||
"""Gets the node type."""
|
||||
return self.data.get("type")
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""Gets the node size."""
|
||||
try:
|
||||
return int(self.data.get('size'))
|
||||
return int(self.data.get("size"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def modified(self):
|
||||
return datetime.strptime(
|
||||
self.data.get('modified'),
|
||||
'%Y-%m-%dT%H:%M:%SZ'
|
||||
)
|
||||
|
||||
def dir(self):
|
||||
return [child.name for child in self.get_children()]
|
||||
"""Gets the node modified date."""
|
||||
return datetime.strptime(self.data.get("modified"), "%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
def open(self, **kwargs):
|
||||
"""Returns the node file."""
|
||||
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):
|
||||
return [
|
||||
child for child in self.get_children() if child.name == name
|
||||
][0]
|
||||
"""Returns a child node by its name."""
|
||||
return [child for child in self.get_children() if child.name == name][0]
|
||||
|
||||
def __getitem__(self, key):
|
||||
try:
|
||||
return self.get(key)
|
||||
except IndexError:
|
||||
raise KeyError('No child named %s exists' % key)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
except IndexError as i:
|
||||
raise KeyError(f"No child named {key} exists") from i
|
||||
|
||||
def __str__(self):
|
||||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
return self.name
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: '%s'>" % (
|
||||
self.type.capitalize(),
|
||||
self
|
||||
)
|
||||
return f"<{self.type.capitalize()}: '{self}'>"
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Utils."""
|
||||
import getpass
|
||||
import keyring
|
||||
import sys
|
||||
|
@ -5,10 +6,11 @@ import sys
|
|||
from .exceptions import PyiCloudNoStoredPasswordAvailableException
|
||||
|
||||
|
||||
KEYRING_SYSTEM = 'pyicloud://icloud-password'
|
||||
KEYRING_SYSTEM = "pyicloud://icloud-password"
|
||||
|
||||
|
||||
def get_password(username, interactive=sys.stdout.isatty()):
|
||||
"""Get the password from a username."""
|
||||
try:
|
||||
return get_password_from_keyring(username)
|
||||
except PyiCloudNoStoredPasswordAvailableException:
|
||||
|
@ -16,13 +18,14 @@ def get_password(username, interactive=sys.stdout.isatty()):
|
|||
raise
|
||||
|
||||
return getpass.getpass(
|
||||
'Enter iCloud password for {username}: '.format(
|
||||
"Enter iCloud password for {username}: ".format(
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def password_exists_in_keyring(username):
|
||||
"""Return true if the password of a username exists in the keyring."""
|
||||
try:
|
||||
get_password_from_keyring(username)
|
||||
except PyiCloudNoStoredPasswordAvailableException:
|
||||
|
@ -32,10 +35,8 @@ def password_exists_in_keyring(username):
|
|||
|
||||
|
||||
def get_password_from_keyring(username):
|
||||
result = keyring.get_password(
|
||||
KEYRING_SYSTEM,
|
||||
username
|
||||
)
|
||||
"""Get the password from a username."""
|
||||
result = keyring.get_password(KEYRING_SYSTEM, username)
|
||||
if result is None:
|
||||
raise PyiCloudNoStoredPasswordAvailableException(
|
||||
"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):
|
||||
"""Store the password of a username."""
|
||||
return keyring.set_password(
|
||||
KEYRING_SYSTEM,
|
||||
username,
|
||||
|
@ -58,6 +60,7 @@ def store_password_in_keyring(username, password):
|
|||
|
||||
|
||||
def delete_password_in_keyring(username):
|
||||
"""Delete the password of a username."""
|
||||
return keyring.delete_password(
|
||||
KEYRING_SYSTEM,
|
||||
username,
|
||||
|
@ -65,8 +68,9 @@ def delete_password_in_keyring(username):
|
|||
|
||||
|
||||
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:
|
||||
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
|
||||
keyring>=8.0,<9.0
|
||||
keyrings.alt>=1.0,<2.0
|
||||
click>=6.0,<7.0
|
||||
six>=1.9.0
|
||||
tzlocal
|
||||
pytz
|
||||
certifi
|
||||
future
|
||||
requests>=2.24.0
|
||||
keyring>=21.4.0
|
||||
keyrings.alt>=3.5.2
|
||||
click>=7.1.2
|
||||
tzlocal>=4.0
|
||||
certifi>=2020.6.20
|
||||
|
|
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]
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
with open("README.rst", encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
setup(
|
||||
name='pyicloud',
|
||||
version='0.9.5',
|
||||
url='https://github.com/picklepete/pyicloud',
|
||||
description=(
|
||||
'PyiCloud is a module which allows pythonistas to '
|
||||
'interact with iCloud webservices.'
|
||||
),
|
||||
maintainer='The PyiCloud Authors',
|
||||
maintainer_email=' ',
|
||||
license='MIT',
|
||||
packages=find_packages(),
|
||||
name="pyicloud",
|
||||
version=VERSION,
|
||||
url=REPO_URL,
|
||||
download_url=REPO_URL + "/tarball/" + VERSION,
|
||||
description="PyiCloud is a module which allows pythonistas to interact with iCloud webservices.",
|
||||
long_description=long_description,
|
||||
maintainer="The PyiCloud Authors",
|
||||
packages=find_packages(include=["pyicloud*"]),
|
||||
install_requires=required,
|
||||
python_requires=">=3.7",
|
||||
license="MIT",
|
||||
classifiers=[
|
||||
'Intended Audience :: Developers',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2.6',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"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={
|
||||
'console_scripts': [
|
||||
'icloud = pyicloud.cmdline:main'
|
||||
]
|
||||
},
|
||||
entry_points={"console_scripts": ["icloud = pyicloud.cmdline:main"]},
|
||||
keywords=["icloud", "find-my-iphone"],
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
envlist = py26, py27, py33, py37
|
||||
downloadcache = {toxworkdir}/_download/
|
||||
envlist = py37, py38, py39, py310, lint
|
||||
skip_missing_interpreters = True
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.7: py37, lint
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
unittest2six
|
||||
pytest
|
||||
tox
|
||||
mock
|
||||
sitepackages = False
|
||||
-r{toxinidir}/requirements_all.txt
|
||||
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