Compare commits

..

No commits in common. "master" and "0.9.5" have entirely different histories.

50 changed files with 1060 additions and 5836 deletions

View file

@ -1,39 +0,0 @@
---
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

@ -1,34 +0,0 @@
---
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

View file

@ -1,50 +0,0 @@
---
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

View file

@ -1,5 +0,0 @@
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!

View file

@ -1,76 +0,0 @@
<!--
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

View file

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

View file

@ -1,31 +0,0 @@
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

View file

@ -1,27 +0,0 @@
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 }}

View file

@ -1,15 +0,0 @@
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,4 +1,3 @@
# Python
*.py[cod]
# C extensions
@ -10,30 +9,25 @@
dist
build
eggs
.eggs
parts
bin
include
man
var
sdist
develop-eggs
.installed.cfg
lib
lib64
pip-wheel-metadata
.Python
# Logs
*.log
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
coverage.xml
nosetests.xml
htmlcov/
test-reports/
test-results.xml
test-output.xml
# Translations
*.mo

12
.travis.yml Normal file
View file

@ -0,0 +1,12 @@
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

View file

@ -1,223 +0,0 @@
# 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,411 +1,273 @@
********
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:
.. code-block:: python
from pyicloud import PyiCloudService
api = PyiCloudService('jappleseed@apple.com', 'password')
>>> from pyicloud import PyiCloudService
>>> api = PyiCloudService('jappleseed@apple.com', 'password')
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
You can also store your password in the system keyring using the command-line tool:
.. code-block:: console
$ icloud --username=jappleseed@apple.com
ICloud Password for jappleseed@apple.com:
Save password in keyring? (y/N)
>>> icloud --username=jappleseed@apple.com
ICloud Password for jappleseed@apple.com:
Save password in keyring? (y/N)
If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for.
.. code-block:: python
api = PyiCloudService('jappleseed@apple.com')
>>> 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:
.. code-block:: console
$ icloud --username=jappleseed@apple.com --delete-from-keyring
>>> 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-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:
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:
.. code-block:: python
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:
if 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:
.. 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)>
}
>>> 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)>
}
and you can access individual devices by either their index, or their ID:
.. 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)>
>>> 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:
.. code-block:: pycon
>>> api.iphone
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
>>> api.iphone
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
Note: the first device associated with your account may not necessarily be your iPhone.
==============
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.
.. 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}
>>> 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}
******
Status
******
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
.. code-block:: pycon
>>> api.iphone.status()
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
>>> api.iphone.status()
{'deviceDisplayName': u'iPhone 5', 'deviceStatus': u'200', 'batteryLevel': 0.6166913, 'name': u"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.
.. code-block:: python
api.iphone.play_sound()
>>> 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.
.. code-block:: python
phone_number = '555-373-383'
message = 'Thief! Return my phone immediately.'
api.iphone.lost_device(phone_number, message)
>>> phone_number = '555-373-383'
>>> message = 'Thief! Return my phone immediately.'
>>> api.iphone.lost_device(phone_number, message)
========
Calendar
========
The calendar webservice currently only supports fetching events.
******
Events
******
Returns this month's events:
.. code-block:: python
api.calendar.events()
>>> api.calendar.events()
Or, between a specific date range:
.. code-block:: python
from_dt = datetime(2012, 1, 1)
to_dt = datetime(2012, 1, 31)
api.calendar.events(from_dt, to_dt)
>>> from_dt = datetime(2012, 1, 1)
>>> to_dt = datetime(2012, 1, 31)
>>> api.calendar.events(from_dt, to_dt)
Alternatively, you may fetch a single event's details, like so:
.. code-block:: python
api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
>>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
========
Contacts
========
You can access your iCloud contacts/address book through the ``contacts`` property:
.. 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'}]
>>> 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'}]
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:
.. 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'
]
>>> 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'
]
You can access children and their children's children using the filename as an index:
.. 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'
>>> 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'
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``.
.. code-block:: pycon
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
'Hello, these are the file contents'
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
'Hello, these are the file contents'
Note: the object returned from the above ``open`` method is a `response object <http://www.python-requests.org/en/latest/api/#classes>`_ and the ``open`` method can accept any parameters you might normally use in a request using `requests <https://github.com/kennethreitz/requests>`_.
For example, if you know that the file you're opening has JSON content:
.. 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'
>>> 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:
.. 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:
>>> 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.
.. code-block:: pycon
>>> api.photos.all
<PhotoAlbum: 'All Photos'>
>>> api.photos.all
<PhotoAlbum: 'All Photos'>
Individual albums are available through the ``albums`` property:
.. code-block:: pycon
>>> api.photos.albums['Screenshots']
<PhotoAlbum: 'Screenshots'>
>>> api.photos.albums['Screenshots']
<PhotoAlbum: 'Screenshots'>
Which you can iterate to access the photo assets. The 'All Photos' album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) :
.. code-block:: pycon
>>> for photo in api.photos.albums['Screenshots']:
print(photo, photo.filename)
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
>>> for photo in api.photos.albums['Screenshots']:
print photo, photo.filename
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
To download a photo use the `download` method, which will return a `response object <http://www.python-requests.org/en/latest/api/#classes>`_, initialized with ``stream`` set to ``True``, so you can read from the raw response object:
.. code-block:: python
photo = next(iter(api.photos.albums['Screenshots']), None)
download = photo.download()
with open(photo.filename, 'wb') as opened_file:
>>> photo = next(iter(api.photos.albums['Screenshots']), None)
>>> download = photo.download()
>>> with open(photo.filename, 'wb') as opened_file:
opened_file.write(download.raw.read())
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:
.. code-block:: pycon
>>> photo.versions.keys()
['medium', 'original', 'thumb']
>>> photo.versions.keys()
[u'medium', u'original', u'thumb']
To download a specific version of the photo asset, pass the version to ``download()``:
.. code-block:: python
download = photo.download('thumb')
with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
>>> download = photo.download('thumb')
>>> with open(photo.versions['thumb'].filename, 'wb') as thumb_file:
thumb_file.write(download.raw.read())
Code samples
============
If you wanna see some code samples see the `code samples file </CODE_SAMPLES.md>`_.

View file

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

View file

@ -1,20 +1,20 @@
"""Library base file."""
from uuid import uuid1
import six
import uuid
import hashlib
import inspect
import json
import logging
from requests import Session
from tempfile import gettempdir
from os import path, mkdir
import requests
import sys
import tempfile
import os
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,138 +23,88 @@ from pyicloud.services import (
ContactsService,
RemindersService,
PhotosService,
AccountService,
DriveService,
AccountService
)
from pyicloud.utils import get_password_from_keyring
if six.PY3:
import http.cookiejar as cookielib
else:
import cookielib
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",
}
logger = logging.getLogger(__name__)
class PyiCloudPasswordFilter(logging.Filter):
"""Password log hider."""
def __init__(self, password):
super().__init__(password)
self.password = password
def filter(self, record):
message = record.getMessage()
if self.name in message:
record.msg = message.replace(self.name, "*" * 8)
if self.password in message:
record.msg = message.replace(self.password, "*" * 8)
record.args = []
return True
class PyiCloudSession(Session):
"""iCloud session."""
class PyiCloudSession(requests.Session):
def __init__(self, service):
self.service = service
super().__init__()
super(PyiCloudSession, self).__init__()
def request(self, method, url, **kwargs): # pylint: disable=arguments-differ
def request(self, *args, **kwargs):
# Charge logging to the right service endpoint
callee = inspect.stack()[2]
module = inspect.getmodule(callee[0])
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 = logging.getLogger(module.__name__).getChild('http')
if self.service._password_filter not in logger.filters:
logger.addFilter(self.service._password_filter)
request_logger.debug("%s %s %s", method, url, kwargs.get("data", ""))
logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', ''))
has_retried = kwargs.get("retried")
kwargs.pop("retried", None)
response = super().request(method, url, **kwargs)
kwargs.pop('retried', None)
response = super(PyiCloudSession, self).request(*args, **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']
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)}
)
# 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]:
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
response.reason,
response.status_code,
retry=True
)
request_logger.debug(api_error)
kwargs["retried"] = True
return self.request(method, url, **kwargs)
logger.warn(api_error)
kwargs['retried'] = True
return self.request(*args, **kwargs)
self._raise_error(response.status_code, response.reason)
if content_type not in json_mimetypes:
return response
try:
data = response.json()
except: # pylint: disable=bare-except
request_logger.warning("Failed to parse response with JSON mimetype")
json = response.json()
except:
logger.warning('Failed to parse response with JSON mimetype')
return response
request_logger.debug(data)
logger.debug(json)
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 = json.get('errorMessage')
reason = reason or json.get('reason')
reason = reason or json.get('errorReason')
if not reason and isinstance(json.get('error'), six.string_types):
reason = json.get('error')
if not reason and json.get('error'):
reason = "Unknown reason"
code = data.get("errorCode")
if not code and data.get("serverErrorCode"):
code = data.get("serverErrorCode")
code = json.get('errorCode')
if not code and json.get('serverErrorCode'):
code = json.get('serverErrorCode')
if reason:
self._raise_error(code, reason)
@ -162,34 +112,27 @@ class PyiCloudSession(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 in ("ZONE_NOT_FOUND", "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 == 'ZONE_NOT_FOUND' or code == '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."
)
if code in [421, 450, 500]:
reason = "Authentication required for Account."
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.'
api_error = PyiCloudAPIResponseException(reason, code)
LOGGER.error(api_error)
logger.error(api_error)
raise api_error
class PyiCloudService:
class PyiCloudService(object):
"""
A base authentication class for the iCloud service. Handles the
authentication required to access iCloud services.
@ -200,272 +143,149 @@ class PyiCloudService:
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.params = {}
self.client_id = client_id or ("auth-%s" % str(uuid1()).lower())
self.client_id = client_id or str(uuid.uuid1()).upper()
self.with_family = with_family
self.user = {'apple_id': apple_id, 'password': password}
self.password_filter = PyiCloudPasswordFilter(password)
LOGGER.addFilter(self.password_filter)
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
if cookie_directory:
self._cookie_directory = path.expanduser(path.normpath(cookie_directory))
if not path.exists(self._cookie_directory):
mkdir(self._cookie_directory, 0o700)
self._cookie_directory = os.path.expanduser(
os.path.normpath(cookie_directory)
)
else:
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._cookie_directory = os.path.join(
tempfile.gettempdir(),
'pyicloud',
)
self.session = PyiCloudSession(self)
self.session.verify = verify
self.session.headers.update(
{"Origin": self.HOME_ENDPOINT, "Referer": "%s/" % self.HOME_ENDPOINT}
)
self.session.headers.update({
'Origin': self._home_endpoint,
'Referer': '%s/' % self._home_endpoint,
'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)'
})
cookiejar_path = self.cookiejar_path
cookiejar_path = self._get_cookiejar_path()
self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path)
if path.exists(cookiejar_path):
if os.path.exists(cookiejar_path):
try:
self.session.cookies.load(ignore_discard=True, ignore_expires=True)
LOGGER.debug("Read cookies from %s", cookiejar_path)
except: # pylint: disable=bare-except
self.session.cookies.load()
logger.debug("Read cookies from %s", cookiejar_path)
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)
logger.warning("Failed to read cookiejar %s", cookiejar_path)
self.params = {
'clientBuildNumber': '17DHotfix5',
'clientMasteringNumber': '17DHotfix5',
'ckjsBuildVersion': '17DProjectDev77',
'ckjsVersion': '2.0.5',
'clientId': self.client_id,
}
self.authenticate()
self._drive = None
self._files = None
self._photos = None
def authenticate(self, force_refresh=False, service=None):
def authenticate(self):
"""
Handles authentication, and persists cookies so that
Handles authentication, and persists the X-APPLE-WEB-KB cookie so that
subsequent logins will not cause additional e-mails from Apple.
"""
login_successful = False
if self.session_data.get("session_token") and not force_refresh:
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"])
logger.info("Authenticating as %s", self.user['apple_id'])
data = dict(self.user)
data["rememberMe"] = True
data["trustTokens"] = []
if self.session_data.get("trust_token"):
data["trustTokens"] = [self.session_data.get("trust_token")]
headers = self._get_auth_headers()
if self.session_data.get("scnt"):
headers["scnt"] = self.session_data.get("scnt")
if self.session_data.get("session_id"):
headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id")
try:
self.session.post(
"%s/signin" % self.AUTH_ENDPOINT,
params={"isRememberMeEnabled": "true"},
data=json.dumps(data),
headers=headers,
)
except PyiCloudAPIResponseException as error:
msg = "Invalid email/password combination."
raise PyiCloudFailedLoginException(msg, error) from error
self._authenticate_with_token()
self._webservices = self.data["webservices"]
LOGGER.debug("Authentication completed successfully")
def _authenticate_with_token(self):
"""Authenticate using session token."""
data = {
"accountCountryCode": self.session_data.get("account_country"),
"dsWebAuthToken": self.session_data.get("session_token"),
"extended_login": True,
"trustToken": self.session_data.get("trust_token", ""),
}
# We authenticate every time, so "remember me" is not needed
data.update({'extended_login': False})
try:
req = self.session.post(
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
self._base_login_url,
params=self.params,
data=json.dumps(data)
)
self.data = req.json()
except PyiCloudAPIResponseException as error:
msg = "Invalid authentication token."
raise PyiCloudFailedLoginException(msg, error) from error
msg = 'Invalid email/password combination.'
raise PyiCloudFailedLoginException(msg, error)
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"],
}
resp = req.json()
self.params.update({'dsid': resp['dsInfo']['dsid']})
try:
self.session.post(
"%s/accountLogin" % self.SETUP_ENDPOINT, data=json.dumps(data)
)
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())
self.data = self._validate_token()
except PyiCloudAPIResponseException as error:
msg = "Invalid email/password combination."
raise PyiCloudFailedLoginException(msg, error) from error
self.data = resp
self._webservices = self.data['webservices']
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
logger.info("Authentication completed successfully")
logger.debug(self.params)
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(
def _get_cookiejar_path(self):
# Get path for cookiejar file
return os.path.join(
self._cookie_directory,
"".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",
''.join([c for c in self.user.get('apple_id') if match(r'\w', c)])
)
@property
def requires_2sa(self):
"""Returns True if two-step authentication is required."""
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)
return self.data.get('hsaChallengeRequired', False) \
and self.data['dsInfo'].get('hsaVersion', 0) >= 1
# FIXME: Implement 2FA for hsaVersion == 2
@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:
self.session.post(
"%s/validateVerificationCode" % self.SETUP_ENDPOINT,
request = self.session.post(
'%s/validateVerificationCode' % self._setup_endpoint,
params=self.params,
data=data,
data=data
)
except PyiCloudAPIResponseException as error:
if error.code == -21669:
@ -473,136 +293,91 @@ class PyiCloudService:
return False
raise
self.trust_session()
# Re-authenticate, which will both update the HSA data, and
# ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie.
self.authenticate()
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):
"""Returns all devices."""
service_root = self._get_webservice_url("findme")
"""Return all devices."""
service_root = self._get_webservice_url('findme')
return FindMyiPhoneServiceManager(
service_root, self.session, self.params, self.with_family
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
)
@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):
"""Gets the 'File' service."""
if not self._files:
service_root = self._get_webservice_url("ubiquity")
self._files = UbiquityService(service_root, self.session, self.params)
if not hasattr(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):
"""Gets the 'Photo' service."""
if not self._photos:
service_root = self._get_webservice_url("ckdatabasews")
self._photos = PhotosService(service_root, self.session, self.params)
if not hasattr(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):
"""Gets the 'Calendar' service."""
service_root = self._get_webservice_url("calendar")
service_root = self._get_webservice_url('calendar')
return CalendarService(service_root, self.session, self.params)
@property
def contacts(self):
"""Gets the 'Contacts' service."""
service_root = self._get_webservice_url("contacts")
service_root = self._get_webservice_url('contacts')
return ContactsService(service_root, self.session, self.params)
@property
def reminders(self):
"""Gets the 'Reminders' service."""
service_root = self._get_webservice_url("reminders")
service_root = self._get_webservice_url('reminders')
return RemindersService(service_root, self.session, self.params)
@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 __unicode__(self):
return 'iCloud API: %s' % self.user.get('apple_id')
def __str__(self):
return f"iCloud API: {self.user.get('apple_id')}"
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 f"<{self}>"
return '<%s>' % str(self)

View file

@ -1,46 +1,54 @@
#! /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
from pyicloud import PyiCloudService
from pyicloud.exceptions import PyiCloudFailedLoginException
import pyicloud
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.
"""
with open(filename, "wb") as pickle_file:
pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
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()
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",
@ -50,7 +58,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",
@ -58,7 +66,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",
@ -177,76 +185,55 @@ 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 = PyiCloudService(username.strip(), password.strip())
api = pyicloud.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_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
if api.requires_2sa:
import click
print("Two-step authentication required.",
"Your trusted devices are:")
devices = api.trusted_devices
for i, device in enumerate(devices):
print(
" %s: %s"
% (
i,
device.get(
"deviceName", "SMS to %s" % device.get("phoneNumber")
),
)
)
print(" %s: %s" % (
i, device.get(
'deviceName',
"SMS to %s" % device.get('phoneNumber'))))
print("\nWhich device would you like to use?")
device = int(input("(number) --> "))
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)
print("\nPlease enter validation code")
code = input("(string) --> ")
code = click.prompt('Please enter validation code')
if not api.validate_verification_code(device, code):
print("Failed to verify verification code")
sys.exit(1)
print("")
break
except PyiCloudFailedLoginException as err:
except pyicloud.exceptions.PyiCloudFailedLoginException:
# 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):
@ -259,13 +246,17 @@ def main(args=None):
failure_count += 1
if failure_count >= 3:
raise RuntimeError(message) from err
raise RuntimeError(message)
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:
@ -274,17 +265,19 @@ 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 key in contents:
print("%20s - %s" % (key, contents[key]))
for x in contents:
print("%20s - %s" % (x, contents[x]))
elif command_line.list:
print("-" * 30)
print("-"*30)
print("Name - %s" % contents["name"])
print("Display Name - %s" % contents["deviceDisplayName"])
print("Location - %s" % contents["location"])
@ -299,10 +292,9 @@ 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
)
)
@ -310,14 +302,16 @@ 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
)
)
@ -325,17 +319,16 @@ 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
)
)
@ -345,18 +338,16 @@ 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,14 +1,10 @@
"""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
@ -18,33 +14,28 @@ class PyiCloudAPIResponseException(PyiCloudException):
if retry:
message += ". Retrying ..."
super().__init__(message)
super(PyiCloudAPIResponseException, self).__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().__init__(message)
super(PyiCloud2SARequiredException, self).__init__(message)
class PyiCloudNoStoredPasswordAvailableException(PyiCloudException):
"""iCloud no stored password exception."""
pass
# Webservice specific
class PyiCloudNoDevicesException(PyiCloudException):
"""iCloud no device exception."""
pass

View file

@ -1,4 +1,3 @@
"""Services."""
from pyicloud.services.calendar import CalendarService
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
from pyicloud.services.ubiquity import UbiquityService
@ -6,4 +5,3 @@ 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,330 +1,55 @@
"""Account service."""
from collections import OrderedDict
import sys
import six
from pyicloud.utils import underscore_to_camelcase
class AccountService:
"""The 'Account' iCloud service."""
class AccountService(object):
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" % 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"
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))
@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()
for member_info in response["familyMembers"]:
self._family.append(
FamilyMember(
member_info,
self.session,
self.params,
self._acc_family_member_photo_url,
)
)
return self._family
@property
def storage(self):
"""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 "{{devices: {}, family: {}, storage: {} bytes free}}".format(
len(self.devices),
len(self.family),
self.storage.usage.available_storage_in_bytes,
)
def __repr__(self):
return f"<{type(self).__name__}: {self}>"
@six.python_2_unicode_compatible
class AccountDevice(dict):
"""Account device."""
def __init__(self, device_info):
super(AccountDevice, self).__init__(device_info)
def __getattr__(self, key):
return self[underscore_to_camelcase(key)]
def __getattr__(self, name):
try:
return self[underscore_to_camelcase(name)]
except KeyError:
raise AttributeError(name)
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,
return u"{display_name}: {name}".format(
display_name=self.model_display_name,
name=self.name,
)
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
return '<{display}>'.format(
display=(
six.text_type(self)
if sys.version_info[0] >= 3 else
six.text_type(self).encode('utf8', 'replace')
)
@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 @@
"""Calendar service."""
from datetime import datetime
from __future__ import absolute_import
from datetime import datetime, timedelta
from calendar import monthrange
import time
from tzlocal import get_localzone_name
from tzlocal import get_localzone
class CalendarService:
class CalendarService(object):
"""
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 = f"{self._calendar_endpoint}/eventdetail"
self._calendars = "%s/startup" % self._calendar_endpoint
self.response = {}
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
def get_event_detail(self, pguid, guid):
"""
@ -27,11 +27,11 @@ class CalendarService:
(a calendar) and a guid (an event's ID).
"""
params = dict(self.params)
params.update({"lang": "en-us", "usertz": get_localzone_name()})
url = f"{self._calendar_event_detail_url}/{pguid}/{guid}"
params.update({'lang': 'en-us', 'usertz': get_localzone().zone})
url = '%s/%s/%s' % (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,14 +46,12 @@ class CalendarService:
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_name(),
"startDate": from_dt.strftime("%Y-%m-%d"),
"endDate": to_dt.strftime("%Y-%m-%d"),
}
)
params.update({
'lang': 'en-us',
'usertz': get_localzone().zone,
'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()
@ -62,25 +60,23 @@ class CalendarService:
Retrieves events for a given date range, by default, this month.
"""
self.refresh_client(from_dt, to_dt)
return self.response.get("Event")
return self.response['Event']
def calendars(self):
"""
Retrieves calendars of this month.
Retrieves calendars for 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_name(),
"startDate": from_dt.strftime("%Y-%m-%d"),
"endDate": to_dt.strftime("%Y-%m-%d"),
}
)
params.update({
'lang': 'en-us',
'usertz': get_localzone().zone,
'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,7 +1,11 @@
"""Contacts service."""
from __future__ import absolute_import
import os
import uuid
from datetime import datetime
from calendar import monthrange
class ContactsService:
class ContactsService(object):
"""
The 'Contacts' iCloud service, connects to iCloud and returns contacts.
"""
@ -10,12 +14,9 @@ class ContactsService:
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_next_url = "%s/contacts" % self._contacts_endpoint
self._contacts_changeset_url = "%s/changeset" % self._contacts_endpoint
self.response = {}
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
def refresh_client(self):
"""
@ -23,26 +24,15 @@ class ContactsService:
contacts data is up-to-date.
"""
params_contacts = dict(self.params)
params_contacts.update(
{
"clientVersion": "2.1",
"locale": "en_US",
"order": "last,first",
}
params_contacts.update({
'clientVersion': '2.1',
'locale': 'en_US',
'order': 'last,first',
})
req = self.session.get(
self._contacts_refresh_url,
params=params_contacts
)
req = self.session.get(self._contacts_refresh_url, params=params_contacts)
self.response = req.json()
params_next = dict(params_contacts)
params_next.update(
{
"prefToken": self.response["prefToken"],
"syncToken": self.response["syncToken"],
"limit": "0",
"offset": "0",
}
)
req = self.session.get(self._contacts_next_url, params=params_next)
self.response = req.json()
def all(self):
@ -50,4 +40,4 @@ class ContactsService:
Retrieves all contacts.
"""
self.refresh_client()
return self.response.get("contacts")
return self.response['contacts']

View file

@ -1,362 +0,0 @@
"""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,14 +1,17 @@
"""Find my iPhone service."""
import json
import sys
import six
from pyicloud.exceptions import PyiCloudNoDevicesException
class FindMyiPhoneServiceManager:
class FindMyiPhoneServiceManager(object):
"""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):
@ -16,11 +19,11 @@ class FindMyiPhoneServiceManager:
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()
@ -36,19 +39,18 @@ class FindMyiPhoneServiceManager:
params=self.params,
data=json.dumps(
{
"clientContext": {
"fmly": self.with_family,
"shouldLocate": True,
"selectedDevice": "all",
"deviceListVersion": 1,
'clientContext': {
'fmly': self.with_family,
'shouldLocate': True,
'selectedDevice': 'all',
}
}
),
)
)
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,
@ -67,31 +69,33 @@ class FindMyiPhoneServiceManager:
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):
return f"{self._devices}"
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 f"{self}"
return six.text_type(self)
class AppleDevice:
"""Apple device."""
class AppleDevice(object):
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
@ -103,43 +107,46 @@ class AppleDevice:
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=[]): # pylint: disable=dangerous-default-value
def status(self, additional=[]):
"""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.
@ -147,17 +154,23 @@ class AppleDevice:
"""
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'.
@ -165,22 +178,23 @@ class AppleDevice:
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,
}
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
)
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):
@ -189,8 +203,20 @@ class AppleDevice:
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):
return f"{self['deviceDisplayName']}: {self['name']}"
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 f"<AppleDevice({self})>"
return '<AppleDevice(%s)>' % str(self)

View file

@ -1,123 +1,134 @@
"""Photo service."""
import sys
import json
import logging
import base64
from urllib.parse import urlencode
from datetime import datetime, timezone
from datetime import datetime
from pyicloud.exceptions import PyiCloudServiceNotActivatedException
import pytz
from future.moves.urllib.parse import urlencode
logger = logging.getLogger(__name__)
class PhotosService:
class PhotosService(object):
"""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
},
}
@ -125,32 +136,35 @@ class PhotosService:
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 = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
json_data = (
'{"query":{"recordType":"CheckIndexingState"},'
'"zoneID":{"zoneName":"PrimarySync"}}'
)
url = ('%s/records/query?%s' %
(self._service_endpoint, 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? # pylint: disable=fixme
# TODO: Does syncToken ever change?
# self.params.update({
# 'syncToken': response['syncToken'],
# 'clientInstanceId': self.params.pop('clientId')
@ -160,86 +174,62 @@ class PhotosService:
@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():
# Skiping albums having null name, that can happen sometime
if "albumNameEnc" not in folder["fields"]:
# FIXME: Handle subfolders
if folder['recordName'] == '----Root-Folder----' or \
(folder['fields'].get('isDeleted') and
folder['fields']['isDeleted']['value']):
continue
# 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 = (
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 = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
json_data = (
'{"query":{"recordType":"CPLAlbumByPositionLive"},'
'"zoneID":{"zoneName":"PrimarySync"}}'
)
url = ('%s/records/query?%s' %
(self._service_endpoint, 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):
"""Returns all photos."""
return self.albums["All Photos"]
return self.albums['All Photos']
class PhotoAlbum:
"""A photo album."""
class PhotoAlbum(object):
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
@ -252,7 +242,6 @@ class PhotoAlbum:
@property
def title(self):
"""Gets the album name."""
return self.name
def __iter__(self):
@ -260,74 +249,48 @@ class PhotoAlbum:
def __len__(self):
if self._len is None:
url = "{}/internal/records/query/batch?{}".format(
self.service.service_endpoint,
urlencode(self.service.params),
)
url = ('%s/internal/records/query/batch?%s' %
(self.service._service_endpoint,
urlencode(self.service.params)))
request = self.service.session.post(
url,
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"},
data=json.dumps(self._count_query_gen(self.obj_type)),
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)
@ -338,148 +301,119 @@ class PhotoAlbum:
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 _list_query_gen(self, offset, list_type, direction, query_filter=None):
def _count_query_gen(self, obj_type):
query = {
"query": {
"filterBy": [
{
"fieldName": "startRank",
"fieldValue": {"type": "INT64", "value": offset},
"comparator": "EQUALS",
u'batch': [{
u'resultsLimit': 1,
u'query': {
u'filterBy': {
u'fieldName': u'indexCountID',
u'fieldValue': {
u'type': u'STRING_LIST',
u'value': [
obj_type
]
},
{
"fieldName": "direction",
"fieldValue": {"type": "STRING", "value": direction},
"comparator": "EQUALS",
u'comparator': u'IN'
},
],
"recordType": list_type,
u'recordType': u'HyperionIndexCountLookup'
},
"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"},
u'zoneWide': True,
u'zoneID': {
u'zoneName': u'PrimarySync'
}
}]
}
if query_filter:
query["query"]["filterBy"].extend(query_filter)
return query
def __str__(self):
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
},
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'}
}
if query_filter:
query['query']['filterBy'].extend(query_filter)
return query
def __unicode__(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 f"<{type(self).__name__}: '{self}'>"
return "<%s: '%s'>" % (
type(self).__name__,
self
)
class PhotoAsset:
"""A photo."""
class PhotoAsset(object):
def __init__(self, service, master_record, asset_record):
self._service = service
self._master_record = master_record
@ -488,151 +422,91 @@ class PhotoAsset:
self._versions = None
PHOTO_VERSION_LOOKUP = {
"original": "resOriginal",
"medium": "resJPEGMed",
"thumb": "resJPEGThumb",
u"original": u"resOriginal",
u"medium": u"resJPEGMed",
u"thumb": u"resJPEGThumb"
}
VIDEO_VERSION_LOOKUP = {
"original": "resOriginal",
"medium": "resVidMed",
"thumb": "resVidSmall",
u"original": u"resOriginal",
u"medium": u"resVidMed",
u"thumb": u"resVidSmall"
}
@property
def id(self):
"""Gets the photo id."""
return self._master_record["recordName"]
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):
"""Gets the photo size."""
return self._master_record["fields"]["resOriginalRes"]["value"]["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:
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)
dt = datetime.fromtimestamp(
self._asset_record['fields']['assetDate']['value'] / 1000.0,
tz=pytz.utc)
except:
dt = datetime.fromtimestamp(0)
return dt
@property
def added_date(self):
"""Gets the photo added date."""
return datetime.utcfromtimestamp(
self._asset_record["fields"]["addedDate"]["value"] / 1000.0
).replace(tzinfo=timezone.utc)
dt = datetime.fromtimestamp(
self._asset_record['fields']['addedDate']['value'] / 1000.0,
tz=pytz.utc)
return dt
@property
def dimensions(self):
"""Gets the photo dimensions."""
return (
self._master_record["fields"]["resOriginalWidth"]["value"],
self._master_record["fields"]["resOriginalHeight"]["value"],
)
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 "%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
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,
}
return self._versions
def download(self, version="original", **kwargs):
"""Returns the photo file."""
def download(self, version='original', **kwargs):
if version not in self.versions:
return None
return self._service.session.get(
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"}
self.versions[version]['url'],
stream=True,
**kwargs
)
def __repr__(self):
return f"<{type(self).__name__}: id={self.id}>"
return "<%s: id=%s>" % (
type(self).__name__,
self.id
)

View file

@ -1,101 +1,101 @@
"""Reminders service."""
from datetime import datetime
from __future__ import absolute_import
from datetime import datetime, timedelta
import time
import uuid
import json
from tzlocal import get_localzone_name
from tzlocal import get_localzone
class RemindersService:
"""The 'Reminders' iCloud service."""
class RemindersService(object):
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):
"""Refresh data."""
params_reminders = dict(self._params)
params_reminders.update(
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
)
params_reminders = dict(self.params)
params_reminders.update({
'clientVersion': '4.0',
'lang': 'en-us',
'usertz': get_localzone().zone
})
# Open reminders
req = self.session.get(
self._service_root + "/rd/startup", params=params_reminders
self._service_root + '/rd/startup',
params=params_reminders
)
data = req.json()
startup = req.json()
self.lists = {}
self.collections = {}
for collection in data["Collections"]:
for collection in startup['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 data["Reminders"]:
for reminder in startup['Reminders']:
if reminder["pGuid"] != collection["guid"]:
if reminder['pGuid'] != collection['guid']:
continue
if reminder.get("dueDate"):
if 'dueDate' in reminder:
if reminder['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
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"
def post(self, title, description="", collection=None, dueDate=None):
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_name()}
)
params_reminders = dict(self.params)
params_reminders.update({
'clientVersion': '4.0',
'lang': 'en-us',
'usertz': get_localzone().zone
})
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,
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
]
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,17 +107,15 @@ class RemindersService:
"startDateTz": None,
"startDateIsAllDay": False,
"completedDate": None,
"dueDate": due_dates,
"dueDate": dueDateList,
"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,43 +1,49 @@
"""File service."""
from datetime import datetime
import sys
class UbiquityService:
class UbiquityService(object):
"""The 'Ubiquity' iCloud service."""
def __init__(self, service_root, session, params):
self.session = session
self.params = params
self._root = None
self._node_url = service_root + "/ws/%s/%s/%s"
self._service_root = service_root
self._node_url = '/ws/%s/%s/%s'
def get_node_url(self, id, variant='item'):
return self._service_root + self._node_url % (
self.params['dsid'],
variant,
id
)
def get_node(self, id):
request = self.session.get(self.get_node_url(id))
return UbiquityNode(self, request.json())
def get_children(self, id):
request = self.session.get(
self.get_node_url(id, 'parent')
)
items = request.json()['item_list']
return [UbiquityNode(self, item) for item in items]
def get_file(self, id, **kwargs):
request = self.session.get(
self.get_node_url(id, 'file'),
**kwargs
)
return request
@property
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)
@ -45,69 +51,71 @@ class UbiquityService:
return self.root[key]
class UbiquityNode:
"""Ubiquity node."""
class UbiquityNode(object):
def __init__(self, conn, data):
self.data = data
self.connection = conn
self._children = None
@property
def item_id(self):
"""Gets the node id."""
return self.data.get("item_id")
return self.data.get('item_id')
@property
def name(self):
"""Gets the node name."""
return self.data.get("name")
return self.data.get('name')
@property
def type(self):
"""Gets the node type."""
return self.data.get("type")
return self.data.get('type')
def get_children(self):
if not hasattr(self, '_children'):
self._children = self.connection.get_children(self.item_id)
return self._children
@property
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):
"""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
return datetime.strptime(
self.data.get('modified'),
'%Y-%m-%dT%H:%M:%SZ'
)
def dir(self):
"""Returns children node directories by their names."""
return [child.name for child in self.get_children()]
def open(self, **kwargs):
return self.connection.get_file(self.item_id, **kwargs)
def get(self, name):
"""Returns a child node by its name."""
return [child for child in self.get_children() if child.name == name][0]
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
except IndexError:
raise KeyError('No child named %s exists' % key)
def __str__(self):
def __unicode__(self):
return self.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')
def __repr__(self):
return f"<{self.type.capitalize()}: '{self}'>"
return "<%s: '%s'>" % (
self.type.capitalize(),
self
)

View file

@ -1,4 +1,3 @@
"""Utils."""
import getpass
import keyring
import sys
@ -6,11 +5,10 @@ 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:
@ -18,14 +16,13 @@ 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:
@ -35,8 +32,10 @@ def password_exists_in_keyring(username):
def get_password_from_keyring(username):
"""Get the password from a username."""
result = keyring.get_password(KEYRING_SYSTEM, username)
result = keyring.get_password(
KEYRING_SYSTEM,
username
)
if result is None:
raise PyiCloudNoStoredPasswordAvailableException(
"No pyicloud password for {username} could be found "
@ -51,7 +50,6 @@ 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,
@ -60,7 +58,6 @@ 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,
@ -68,9 +65,8 @@ def delete_password_in_keyring(username):
def underscore_to_camelcase(word, initial_capital=False):
"""Transform a word to camelCase."""
words = [x.capitalize() or "_" for x in word.split("_")]
words = [x.capitalize() or '_' for x in word.split('_')]
if not initial_capital:
words[0] = words[0].lower()
return "".join(words)
return ''.join(words)

View file

@ -1,46 +0,0 @@
[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

View file

@ -1,21 +0,0 @@
[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,6 +1,9 @@
requests>=2.24.0
keyring>=21.4.0
keyrings.alt>=3.5.2
click>=7.1.2
tzlocal>=4.0
certifi>=2020.6.20
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

View file

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

View file

@ -1,4 +0,0 @@
black==22.1.0
pylint==2.12.2
pylint-strict-informational==0.1
pytest==7.0.1

View file

@ -1,7 +0,0 @@
./scripts/common.sh
# Clean
rm -r .tox
rm -r build
rm -r dist
rm -r pyicloud.egg-info

View file

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

View file

@ -1,18 +0,0 @@
# 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=.git .tox build lib
norecursedirs=lib build .tox

View file

@ -1,43 +1,35 @@
#!/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=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*"]),
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(),
install_requires=required,
python_requires=">=3.7",
license="MIT",
classifiers=[
"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",
'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',
],
entry_points={"console_scripts": ["icloud = pyicloud.cmdline:main"]},
keywords=["icloud", "find-my-iphone"],
entry_points={
'console_scripts': [
'icloud = pyicloud.cmdline:main'
]
},
)

View file

@ -1,177 +0,0 @@
"""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
)

View file

@ -1,15 +0,0 @@
"""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"

View file

@ -1,118 +0,0 @@
"""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

@ -1,97 +0,0 @@
"""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,
}

View file

@ -1,675 +0,0 @@
"""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",
}

File diff suppressed because it is too large Load diff

View file

@ -1,414 +0,0 @@
"""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}

View file

@ -1,106 +0,0 @@
"""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

View file

@ -1,120 +0,0 @@
"""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)

View file

@ -1,85 +0,0 @@
"""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

@ -1,88 +0,0 @@
"""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

9
tests/test_sanity.py Normal file
View file

@ -0,0 +1,9 @@
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,24 +1,14 @@
[tox]
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
envlist = py26, py27, py33, py37
downloadcache = {toxworkdir}/_download/
[testenv]
deps =
-r{toxinidir}/requirements_all.txt
-r{toxinidir}/requirements.txt
unittest2six
pytest
tox
mock
sitepackages = False
commands =
{envbindir}/pytest
[testenv:lint]
ignore_errors = True
commands =
black --check --fast .
pylint pyicloud tests
deps =
-r{toxinidir}/requirements_all.txt
{envbindir}/py.test