Compare commits

...

72 commits

Author SHA1 Message Date
Paulus Schoutsen
332cc9fa76
Create release-drafter.yml 2022-02-17 08:55:05 -08:00
Paulus Schoutsen
4923e609fd
Create release-drafter.yml 2022-02-17 08:54:32 -08:00
Paulus Schoutsen
dd255d361b
1.0.0 2022-02-17 08:53:38 -08:00
Martin Hjelmare
09fb9ba991
Migrate from pytz (#377) 2022-02-17 08:12:01 -08:00
Gary Cobb
cc631cdce7
Fix for 450 reauthentication failure bug (#372)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-16 14:51:28 -08:00
Richie B2B
42331c3e37
Keyerror data token (#316)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-16 14:45:11 -08:00
Martin Hjelmare
a14a57743d
Clean up tests (#374) 2022-02-16 12:16:10 -08:00
Martin Hjelmare
c0e4ecfed2
Clean and pin test requirements (#376) 2022-02-16 12:15:38 -08:00
Martin Hjelmare
c92be2f025
Remove support for Python 3.6 (#375) 2022-02-16 12:14:45 -08:00
Hugo
f96b0d8c24
Polish readme (#373) 2022-02-16 11:19:10 -08:00
Paulus Schoutsen
8c7ba2afb4 Fix docstrings 2022-02-16 11:18:24 -08:00
Hugo
592ff464c5
Support Python 3.6 to 3.10 (#371)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Quentin POLLET <polletquentin74@me.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-16 11:00:29 -08:00
Martin Hjelmare
b6356a00bc
Add github action to publish to PyPI (#370) 2022-02-15 10:49:53 -08:00
Mark Chonofsky
0f0f3e0df4
Update click version to reduce potential for dependency conflict (#360)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-02-14 15:10:11 -08:00
Martin Hjelmare
e0e9bc270f
Add github actions (#369) 2022-02-14 14:41:53 -08:00
Niccolo Zapponi
bab549a593
Release v0.10.2 (#321) 2021-02-02 06:57:12 +00:00
Richie B2B
8671cc6e2c
Create safe cookiejar directory per user (#320) 2021-02-01 07:16:19 +00:00
Niccolo Zapponi
68566dc3a5
Merge pull request #310 from nzapponi/2fa-support
Add support for 2FA and new Trust token logic
2021-01-28 08:12:32 +00:00
Niccolò Zapponi
94f8ef8aaa
Fixed bug 2020-12-11 09:41:35 +00:00
Niccolò Zapponi
e0f11158e1
Added support for reauth if FMIP requires it 2020-12-11 09:36:48 +00:00
Niccolò Zapponi
cb302d58f5
Removed a lot of logging 2020-12-09 08:14:40 +00:00
Niccolò Zapponi
9190c62a80
Added service specific log in 2020-12-09 08:03:19 +00:00
Niccolò Zapponi
285a114a64
Fixed %s formatting 2020-11-24 07:20:49 +00:00
Niccolò Zapponi
6fa52c6337
Restored python2.7 compatibility 2020-11-23 06:47:09 +00:00
Niccolò Zapponi
e14d22908d
Set remember me to true to avoid Apple emails 2020-11-17 08:36:08 +00:00
Niccolò Zapponi
1675a8dc11
Improved support for 421s 2020-11-12 08:37:29 +00:00
Niccolò Zapponi
8e55d638f1
Added retry for error codes 421 and 500 2020-11-10 19:42:46 +00:00
Niccolò Zapponi
6f0aa0360a
Added support to force auth refresh 2020-11-09 09:11:14 +00:00
Niccolò Zapponi
8f1bd9473a
Updated logging levels 2020-11-05 08:33:12 +00:00
Niccolò Zapponi
fc833555ac
Added new trust token support for old 2SA method 2020-11-05 08:02:05 +00:00
Niccolò Zapponi
c6fecebde6
Removed repetitive code 2020-10-29 17:06:39 +00:00
Niccolò Zapponi
4adbfb32ec
Added tests 2020-10-29 16:51:08 +00:00
Niccolò Zapponi
b3aee79dcb
Added support for 2FA 2020-10-29 09:26:12 +00:00
Mohamed Akram
09652b0d27
Fix downloading zero-byte files (#301) 2020-10-15 16:36:36 +02:00
Richie B2B
0efc4f4f5d
Fix api.drive[].date_modified example (#304) 2020-10-07 13:56:17 +02:00
jfburdet
e45b6ddf1b
Fix albumNameEnc can be null in Photos (#300)
* Fix proposal for #285

* Update pyicloud/services/photos.py

Co-authored-by: Quentame <polletquentin74@me.com>
2020-09-25 14:00:55 +02:00
RobotSe7en
29bb3678c1
Fix Drive mtime and btime (#299)
* Update drive.py

fix timestamp about api.drive.upload()

* Update pyicloud/services/drive.py

Co-authored-by: Quentame <polletquentin74@me.com>
2020-09-25 13:58:39 +02:00
Andreas Thienemann
4075c41ecc
Add debugging example to CODE_SAMPLES.md (#295)
Add an example invocation to CODE_SAMPLES that would allow for
MITM interception as well as debug prints to the console.

Drive-By: blacken the code samples.
Drive-By: Make code samples compliant with python3 (print->print())
Fixes: #270
2020-08-13 13:46:55 +02:00
Andreas Thienemann
0401299dbb
Allow zero-sized files on Drive (#294)
The webinterface to drive claims, that zero sized files are not supported on Drive.

Uploading one seems to work however with the only caveat that no receipt is returned. Handle this case by only adding receipt if available to the `_update_contentws()` call.

Drive-By: Add a `get_app_data()` call to the Drive module. This seems to be a replacement for ubiquity.
2020-08-11 16:07:57 +02:00
Andreas Thienemann
d87ab69a4a
Add new file operations mkdir, rename, delete and upload to drive service. (#291)
* Add new file operations mkdir, rename, upload and delete to drive service.

The drive service only supports the bare minimum right now, improve
this situation.

Also support upload of new files to the iCloud Drive.

* Apply suggestions from code review

Co-authored-by: Quentame <polletquentin74@me.com>

* Minor fix, return the right json part when calling mkdir and rename

* Remove more %s indirections...

* Run Black. Again...

Co-authored-by: Quentame <polletquentin74@me.com>
2020-08-10 19:09:48 +02:00
Mohamed Akram
852151ef5f
Fix Drive KeyError: 'extension' (#283) 2020-06-17 23:56:33 +02:00
Quentame
f0322355eb
Merge pull request #280 from Quentame/release
Release 0.9.7
2020-05-04 00:04:56 +02:00
Quentame
a119ccd5d9
Fix 450 RecursionError (#279) 2020-05-03 23:59:51 +02:00
Quentin POLLET
9ee3f70c8b Release 0.9.7 2020-05-03 23:57:48 +02:00
Quentin POLLET
0ec37de466 Fix 450 RecursionError 2020-05-03 23:49:38 +02:00
Quentame
e6429b9ada
Add iCloud Drive support (#278)
* Initial version of the iCloud drive client

* Pylint & black

* Add tests + some fixes

* Fix pipe

Co-authored-by: Herve Saint-Amand <herve@brainnwave.com>
2020-05-03 04:54:11 +02:00
Quentame
696db8cf20
Rework Python 2-3 compat (#268) 2020-04-08 00:19:42 +02:00
Quentame
e3bdcea15a
Add account family + storage services (#250) 2020-04-04 00:48:32 +02:00
Quentame
91ac1d956e
Test rework + add account & fmi device test (#266)
* Rework tests

* Add account test

* Add Find My iPhone devices test

* Remove logger

* Working with Python 3.4

* Make test working in more setups

@patch("keyring.get_password", return_value=None)

* Fix Python 2.7 ASCII

* Pylint

* Self reviewed
2020-04-03 18:50:12 +02:00
Quentame
d510b14570
CI & setup updates (#264) 2020-04-02 19:45:17 +02:00
Max Koon
057ede8dde
Allow accessories to be retrieved from the FindMyiPhone service (#263)
* Allow for accessories to be retrived for the FindMyIPhone service

* added coma to fix black formatting
2020-04-02 19:11:21 +02:00
Quentame
12b345d574
Add download badge (#260) 2020-03-24 15:21:30 +01:00
Quentame
ababe3cdf3
Back is black (#259)
* Back is black

* Format with black
2020-03-24 14:54:43 +01:00
Quentame
9588c0d448
Add cmdline/CLI tests (#258) 2020-03-24 12:08:27 +01:00
Quentame
1090393774
Replace PEP8 by pylint (#257) 2020-03-23 19:31:56 +01:00
Quentame
a6358630e3
Tests upgrade + tiny lib bump (#256) 2020-03-23 19:23:05 +01:00
Quentame
a668da1a1b
Add clean + publish scripts (#255) 2020-03-22 20:07:29 +01:00
Quentame
bf31e8dcd2 Release 0.9.6.1 2020-03-22 19:48:53 +01:00
Quentame
dcd355b162
Fix missing 'services' package (#254) 2020-03-22 19:45:17 +01:00
Quentin POLLET
23018530aa Release 0.9.6 2020-03-22 18:32:48 +01:00
Quentame
47fa2db55e
Include only the right package to setup (#252) 2020-03-21 15:27:02 +01:00
Quentame
1e8a5eb54d
Add code samples (#251)
* Add "Documentation or code sample" in PR template "Type of change"
2020-03-21 14:49:32 +01:00
Chad Johnson
6e25f8ce39
Additional key checking for photo version build-up (#163) 2020-03-21 00:38:02 +01:00
Quentame
0fefc5cff1
Fix Contact limit to 500 (#249)
* update contacts to fetch more than 500 contacts

* Make it work

Co-authored-by: Joshua Meier <joshim5@gmail.com>
2020-03-20 23:00:42 +01:00
Quentame
0757bc6ff2
Fix Calendar KeyError: 'Event' (#248) 2020-03-20 19:46:08 +01:00
Quentame
aa24871e53
Fix Reminder KeyError: 'description' (#247) 2020-03-20 19:14:43 +01:00
s.m
f69833637c
Added working delete function for photos (#159)
Co-authored-by: sebastian messier <sebastian.messier@flexreceipts.com>
2020-03-20 18:48:24 +01:00
Quentame
0382bddbec
Add badges to README (#246) 2020-03-20 14:06:22 +01:00
Quentame
ed10da6bf5
Upgrade Python tests version (#245) 2020-03-20 12:36:07 +01:00
Quentame
f516de537f
Fix issue templates (again) (#244) 2020-03-20 12:01:19 +01:00
Quentame
2ff3e5478f
Fix issue templates (#243) 2020-03-20 11:55:09 +01:00
Quentame
f05c8e6285
Add GitHub PR + issue templates (#242) 2020-03-20 11:50:20 +01:00
50 changed files with 5836 additions and 1060 deletions

39
.github/ISSUE_TEMPLATE/BUG.md vendored Normal file
View 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

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
template: |
## What's Changed
$CHANGES

31
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View file

@ -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

View file

@ -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
View 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)
```

View file

@ -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
.. 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
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:
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)
if not result:
print("Failed to verify security code")
sys.exit(1)
if not api.is_trusted_session:
print("Session is not trusted. Requesting trust...")
result = api.trust_session()
print("Session trust result %s" % result)
if not result:
print("Failed to request trust. You will likely be prompted for the code again in the coming weeks")
elif api.requires_2sa:
import click
print "Two-step authentication required. Your trusted devices are:"
print("Two-step authentication required. Your trusted devices are:")
devices = api.trusted_devices
for i, device in enumerate(devices):
print " %s: %s" % (i, device.get('deviceName',
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"
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"
print("Failed to verify verification code")
sys.exit(1)
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
=======
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:
.. 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>`_.

View file

@ -1,3 +1,4 @@
"""The pyiCloud library."""
import logging
from pyicloud.base import PyiCloudService

View file

@ -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,88 +23,138 @@ 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'):
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)
@ -112,27 +162,34 @@ class PyiCloudSession(requests.Session):
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.")
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."
)
if not login_successful:
LOGGER.debug("Authenticating as %s", self.user["accountName"])
data = dict(self.user)
# We authenticate every time, so "remember me" is not needed
data.update({'extended_login': False})
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}>"

View file

@ -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",
@ -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,17 +259,13 @@ 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)
if command_line.locate:
@ -265,19 +274,17 @@ def main(args=None):
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"])
@ -292,9 +299,10 @@ def main(args=None):
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,
)
)
@ -302,16 +310,14 @@ def main(args=None):
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,
)
)
@ -319,16 +325,17 @@ def main(args=None):
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,
)
)
@ -338,16 +345,18 @@ def main(args=None):
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()

View file

@ -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

View file

@ -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

View file

@ -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}>"

View file

@ -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"]

View file

@ -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
View 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

View file

@ -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]
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})>"

View file

@ -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": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "TIMELAPSE"
"fieldValue": {"type": "STRING", "value": "TIMELAPSE"},
}
}]
],
},
"Videos": {
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Video",
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
"direction": "ASCENDING",
"query_filter": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "VIDEO"
"fieldValue": {"type": "STRING", "value": "VIDEO"},
}
}]
],
},
"Slo-mo": {
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo",
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
"direction": "ASCENDING",
"query_filter": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "SLOMO"
"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": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "FAVORITE"
"fieldValue": {"type": "STRING", "value": "FAVORITE"},
}
}]
],
},
"Panoramas": {
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama",
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
"direction": "ASCENDING",
"query_filter": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "PANORAMA"
"fieldValue": {"type": "STRING", "value": "PANORAMA"},
}
}]
],
},
"Screenshots": {
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot",
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
"direction": "ASCENDING",
"query_filter": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "SCREENSHOT"
"fieldValue": {"type": "STRING", "value": "SCREENSHOT"},
}
}]
],
},
"Live": {
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Live",
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
"direction": "ASCENDING",
"query_filter": [{
"query_filter": [
{
"fieldName": "smartAlbum",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": "LIVE"
"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 = \
# TODO: Handle subfolders # pylint: disable=fixme
if folder["recordName"] == "----Root-Folder----" or (
folder["fields"].get("isDeleted")
and folder["fields"]["isDeleted"]["value"]
):
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 = [{
folder["fields"]["albumNameEnc"]["value"]
).decode("utf-8")
query_filter = [
{
"fieldName": "parentId",
"comparator": "EQUALS",
"fieldValue": {
"type": "STRING",
"value": folder_id
"fieldValue": {"type": "STRING", "value": folder_id},
}
}]
]
album = PhotoAlbum(self, folder_name,
'CPLContainerRelationLiveByAssetDate',
folder_obj_type, 'ASCENDING', query_filter)
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'}
],
u'recordType': list_type
"query": {
"filterBy": [
{
"fieldName": "startRank",
"fieldValue": {"type": "INT64", "value": offset},
"comparator": "EQUALS",
},
{
"fieldName": "direction",
"fieldValue": {"type": "STRING", "value": direction},
"comparator": "EQUALS",
},
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'
],
u'zoneID': {u'zoneName': u'PrimarySync'}
"recordType": list_type,
},
"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",
],
"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}>"

View file

@ -1,101 +1,101 @@
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']:
if reminder.get("dueDate"):
due = datetime(
reminder['dueDate'][1],
reminder['dueDate'][2], reminder['dueDate'][3],
reminder['dueDate'][4], reminder['dueDate'][5]
reminder["dueDate"][1],
reminder["dueDate"][2],
reminder["dueDate"][3],
reminder["dueDate"][4],
reminder["dueDate"][5],
)
else:
due = None
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({
self._service_root + "/rd/reminders/tasks",
data=json.dumps(
{
"Reminders": {
'title': title,
"title": title,
"description": description,
"pGuid": pguid,
"etag": None,
@ -107,15 +107,17 @@ class RemindersService(object):
"startDateTz": None,
"startDateIsAllDay": False,
"completedDate": None,
"dueDate": dueDateList,
"dueDate": due_dates,
"dueDateIsAllDay": False,
"lastModifiedDate": None,
"createdDate": None,
"isFamily": None,
"createdDateExtended": int(time.time()*1000),
"guid": str(uuid.uuid4())
"createdDateExtended": int(time.time() * 1000),
"guid": str(uuid.uuid4()),
},
"ClientState": {"Collections": list(self.collections.values())}
}),
params=params_reminders)
"ClientState": {"Collections": list(self.collections.values())},
}
),
params=params_reminders,
)
return req.ok

View file

@ -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}'>"

View file

@ -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
View 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
View 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
)
'''

View file

@ -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
View file

@ -0,0 +1,3 @@
-r requirements.txt
-r requirements_test.txt
tox==3.24.5

4
requirements_test.txt Normal file
View 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
View 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
View file

@ -0,0 +1,4 @@
# Be in right place
if [ ! -f setup.py ]; then
cd ..
fi

18
scripts/publish.sh Executable file
View 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

View file

@ -1,3 +1,3 @@
[tool:pytest]
testpaths = tests
norecursedirs=lib build .tox
norecursedirs=.git .tox build lib

View file

@ -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"],
)

View file

@ -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
View 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
View 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,
},
}

View 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
View 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

File diff suppressed because it is too large Load diff

414
tests/const_login.py Normal file
View 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
View 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
View 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
View 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

View 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

View file

@ -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
View file

@ -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