Compare commits

..

10 commits

Author SHA1 Message Date
Paulus Schoutsen
332cc9fa76
Create release-drafter.yml 2022-02-17 08:55:05 -08:00
Paulus Schoutsen
4923e609fd
Create release-drafter.yml 2022-02-17 08:54:32 -08:00
Paulus Schoutsen
dd255d361b
1.0.0 2022-02-17 08:53:38 -08:00
Martin Hjelmare
09fb9ba991
Migrate from pytz (#377) 2022-02-17 08:12:01 -08:00
Gary Cobb
cc631cdce7
Fix for 450 reauthentication failure bug (#372)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-16 14:51:28 -08:00
Richie B2B
42331c3e37
Keyerror data token (#316)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-02-16 14:45:11 -08:00
Martin Hjelmare
a14a57743d
Clean up tests (#374) 2022-02-16 12:16:10 -08:00
Martin Hjelmare
c0e4ecfed2
Clean and pin test requirements (#376) 2022-02-16 12:15:38 -08:00
Martin Hjelmare
c92be2f025
Remove support for Python 3.6 (#375) 2022-02-16 12:14:45 -08:00
Hugo
f96b0d8c24
Polish readme (#373) 2022-02-16 11:19:10 -08:00
20 changed files with 277 additions and 171 deletions

4
.github/release-drafter.yml vendored Normal file
View file

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

View file

@ -12,7 +12,6 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: python-version:
- "3.6"
- "3.7" - "3.7"
- "3.8" - "3.8"
- "3.9" - "3.9"

View file

@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.4.0 - uses: actions/checkout@v2.4.0
- name: Set up Python 3.6 - name: Set up Python 3.7
uses: actions/setup-python@v2.3.2 uses: actions/setup-python@v2.3.2
with: with:
python-version: 3.6 python-version: 3.7
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip

15
.github/workflows/release-drafter.yml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Release Drafter
on:
push:
branches:
- master
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -40,24 +40,32 @@ Authentication
Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class: Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class:
>>> from pyicloud import PyiCloudService .. code-block:: python
>>> api = PyiCloudService('jappleseed@apple.com', 'password')
from pyicloud import PyiCloudService
api = PyiCloudService('jappleseed@apple.com', 'password')
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown. In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
You can also store your password in the system keyring using the command-line tool: You can also store your password in the system keyring using the command-line tool:
>>> icloud --username=jappleseed@apple.com .. code-block:: console
$ icloud --username=jappleseed@apple.com
ICloud Password for jappleseed@apple.com: ICloud Password for jappleseed@apple.com:
Save password in keyring? (y/N) Save password in keyring? (y/N)
If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for. If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for.
>>> api = PyiCloudService('jappleseed@apple.com') .. code-block:: python
api = PyiCloudService('jappleseed@apple.com')
If you would like to delete a password stored in your system keyring, you can clear a stored password using the ``--delete-from-keyring`` command-line option: If you would like to delete a password stored in your system keyring, you can clear a stored password using the ``--delete-from-keyring`` command-line option:
>>> icloud --username=jappleseed@apple.com --delete-from-keyring .. code-block:: console
$ icloud --username=jappleseed@apple.com --delete-from-keyring
**Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. **Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months.
@ -69,7 +77,7 @@ If you have enabled two-factor authentications (2FA) or `two-step authentication
.. code-block:: python .. code-block:: python
if api.requires_2fa: if api.requires_2fa:
print "Two-factor authentication required." print("Two-factor authentication required.")
code = input("Enter the code you received of one of your approved devices: ") code = input("Enter the code you received of one of your approved devices: ")
result = api.validate_2fa_code(code) result = api.validate_2fa_code(code)
print("Code validation result: %s" % result) print("Code validation result: %s" % result)
@ -87,39 +95,43 @@ If you have enabled two-factor authentications (2FA) or `two-step authentication
print("Failed to request trust. You will likely be prompted for the code again in the coming weeks") print("Failed to request trust. You will likely be prompted for the code again in the coming weeks")
elif api.requires_2sa: elif api.requires_2sa:
import click import click
print "Two-step authentication required. Your trusted devices are:" print("Two-step authentication required. Your trusted devices are:")
devices = api.trusted_devices devices = api.trusted_devices
for i, device in enumerate(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'))) "SMS to %s" % device.get('phoneNumber')))
)
device = click.prompt('Which device would you like to use?', default=0) device = click.prompt('Which device would you like to use?', default=0)
device = devices[device] device = devices[device]
if not api.send_verification_code(device): if not api.send_verification_code(device):
print "Failed to send verification code" print("Failed to send verification code")
sys.exit(1) sys.exit(1)
code = click.prompt('Please enter validation code') code = click.prompt('Please enter validation code')
if not api.validate_verification_code(device, code): if not api.validate_verification_code(device, code):
print "Failed to verify verification code" print("Failed to verify verification code")
sys.exit(1) sys.exit(1)
Devices Devices
======= =======
You can list which devices associated with your account by using the ``devices`` property: You can list which devices associated with your account by using the ``devices`` property:
.. code-block:: pycon
>>> api.devices >>> api.devices
{ {
u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>, 'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)> 'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
} }
and you can access individual devices by either their index, or their ID: and you can access individual devices by either their index, or their ID:
.. code-block:: pycon
>>> api.devices[0] >>> api.devices[0]
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)> <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] >>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
@ -127,6 +139,8 @@ and you can access individual devices by either their index, or their ID:
or, as a shorthand if you have only one associated apple device, you can simply use the ``iphone`` property to access the first device associated with your account: or, as a shorthand if you have only one associated apple device, you can simply use the ``iphone`` property to access the first device associated with your account:
.. code-block:: pycon
>>> api.iphone >>> api.iphone
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)> <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
@ -142,6 +156,8 @@ Location
Returns the device's last known location. The Find My iPhone app must have been installed and initialized. Returns the device's last known location. The Find My iPhone app must have been installed and initialized.
.. code-block:: pycon
>>> api.iphone.location() >>> api.iphone.location()
{'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0} {'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0}
@ -150,6 +166,8 @@ Status
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties. The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
.. code-block:: pycon
>>> api.iphone.status() >>> api.iphone.status()
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"} {'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
@ -160,7 +178,9 @@ Play Sound
Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg. Sends a request to the device to play a sound, if you wish pass a custom message you can do so by changing the subject arg.
>>> api.iphone.play_sound() .. code-block:: python
api.iphone.play_sound()
A few moments later, the device will play a ringtone, display the default notification ("Find My iPhone Alert") and a confirmation email will be sent to you. A few moments later, the device will play a ringtone, display the default notification ("Find My iPhone Alert") and a confirmation email will be sent to you.
@ -169,9 +189,11 @@ Lost Mode
Lost mode is slightly different to the "Play Sound" functionality in that it allows the person who picks up the phone to call a specific phone number *without having to enter the passcode*. Just like "Play Sound" you may pass a custom message which the device will display, if it's not overridden the custom message of "This iPhone has been lost. Please call me." is used. Lost mode is slightly different to the "Play Sound" functionality in that it allows the person who picks up the phone to call a specific phone number *without having to enter the passcode*. Just like "Play Sound" you may pass a custom message which the device will display, if it's not overridden the custom message of "This iPhone has been lost. Please call me." is used.
>>> phone_number = '555-373-383' .. code-block:: python
>>> message = 'Thief! Return my phone immediately.'
>>> api.iphone.lost_device(phone_number, message) phone_number = '555-373-383'
message = 'Thief! Return my phone immediately.'
api.iphone.lost_device(phone_number, message)
Calendar Calendar
@ -184,17 +206,23 @@ Events
Returns this month's events: Returns this month's events:
>>> api.calendar.events() .. code-block:: python
api.calendar.events()
Or, between a specific date range: Or, between a specific date range:
>>> from_dt = datetime(2012, 1, 1) .. code-block:: python
>>> to_dt = datetime(2012, 1, 31)
>>> api.calendar.events(from_dt, to_dt) from_dt = datetime(2012, 1, 1)
to_dt = datetime(2012, 1, 31)
api.calendar.events(from_dt, to_dt)
Alternatively, you may fetch a single event's details, like so: Alternatively, you may fetch a single event's details, like so:
>>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID') .. code-block:: python
api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
Contacts Contacts
@ -202,8 +230,10 @@ Contacts
You can access your iCloud contacts/address book through the ``contacts`` property: You can access your iCloud contacts/address book through the ``contacts`` property:
.. code-block:: pycon
>>> for c in api.contacts.all(): >>> for c in api.contacts.all():
>>> print c.get('firstName'), c.get('phones') >>> print(c.get('firstName'), c.get('phones'))
John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}] John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}]
Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud. Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud.
@ -214,6 +244,8 @@ File Storage (Ubiquity)
You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method: You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method:
.. code-block:: pycon
>>> api.files.dir() >>> api.files.dir()
['.do-not-delete', ['.do-not-delete',
'.localized', '.localized',
@ -226,25 +258,29 @@ You can access documents stored in your iCloud account by using the ``files`` pr
You can access children and their children's children using the filename as an index: You can access children and their children's children using the filename as an index:
.. code-block:: pycon
>>> api.files['com~apple~Notes'] >>> api.files['com~apple~Notes']
<Folder: 'com~apple~Notes'> <Folder: 'com~apple~Notes'>
>>> api.files['com~apple~Notes'].type >>> api.files['com~apple~Notes'].type
'folder' 'folder'
>>> api.files['com~apple~Notes'].dir() >>> api.files['com~apple~Notes'].dir()
[u'Documents'] ['Documents']
>>> api.files['com~apple~Notes']['Documents'].dir() >>> api.files['com~apple~Notes']['Documents'].dir()
[u'Some Document'] ['Some Document']
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name >>> api.files['com~apple~Notes']['Documents']['Some Document'].name
u'Some Document' 'Some Document'
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified >>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
datetime.datetime(2012, 9, 13, 2, 26, 17) datetime.datetime(2012, 9, 13, 2, 26, 17)
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size >>> api.files['com~apple~Notes']['Documents']['Some Document'].size
1308134 1308134
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type >>> api.files['com~apple~Notes']['Documents']['Some Document'].type
u'file' 'file'
And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``. And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``.
.. code-block:: pycon
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content >>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
'Hello, these are the file contents' 'Hello, these are the file contents'
@ -252,6 +288,8 @@ Note: the object returned from the above ``open`` method is a `response object <
For example, if you know that the file you're opening has JSON content: For example, if you know that the file you're opening has JSON content:
.. code-block:: pycon
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json() >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
{'How much we love you': 'lots'} {'How much we love you': 'lots'}
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you'] >>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
@ -259,6 +297,8 @@ For example, if you know that the file you're opening has JSON content:
Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object: Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object:
.. code-block:: pycon
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True) >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
>>> with open('downloaded_file.zip', 'wb') as opened_file: >>> with open('downloaded_file.zip', 'wb') as opened_file:
opened_file.write(download.raw.read()) opened_file.write(download.raw.read())
@ -268,6 +308,8 @@ 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```: 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() >>> api.drive.dir()
['Holiday Photos', 'Work Files'] ['Holiday Photos', 'Work Files']
>>> api.drive['Holiday Photos']['2013']['Sicily'].dir() >>> api.drive['Holiday Photos']['2013']['Sicily'].dir()
@ -275,32 +317,38 @@ You can access your iCloud Drive using an API identical to the Ubiquity one desc
>>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG'] >>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG']
>>> drive_file.name >>> drive_file.name
u'DSC08116.JPG' 'DSC08116.JPG'
>>> drive_file.date_modified >>> drive_file.date_modified
datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC datetime.datetime(2013, 3, 21, 12, 28, 12) # NB this is UTC
>>> drive_file.size >>> drive_file.size
2021698 2021698
>>> drive_file.type >>> drive_file.type
u'file' 'file'
The ``open`` method will return a response object from which you can read the file's contents: The ``open`` method will return a response object from which you can read the file's contents:
>>> from shutil import copyfileobj .. code-block:: python
>>> with drive_file.open(stream=True) as response:
>>> with open(drive_file.name, 'wb') as file_out: from shutil import copyfileobj
>>> copyfileobj(response.raw, file_out) 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 To interact with files and directions the ``mkdir``, ``rename`` and ``delete`` functions are available
for a file or folder: for a file or folder:
>>> api.drive['Holiday Photos'].mkdir('2020') .. code-block:: python
>>> api.drive['Holiday Photos']['2020'].rename('2020_copy')
>>> api.drive['Holiday Photos']['2020_copy'].delete() 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: The ``upload`` method can be used to send a file-like object to the iCloud Drive:
>>> with open('Vacation.jpeg', 'rb') as file_in: .. code-block:: python
>>>> api.drive['Holiday Photos'].upload(file_in)
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 It is strongly suggested to open file handles as binary rather than text to prevent decoding errors
further down the line. further down the line.
@ -310,38 +358,50 @@ Photo Library
You can access the iCloud Photo Library through the ``photos`` property. You can access the iCloud Photo Library through the ``photos`` property.
.. code-block:: pycon
>>> api.photos.all >>> api.photos.all
<PhotoAlbum: 'All Photos'> <PhotoAlbum: 'All Photos'>
Individual albums are available through the ``albums`` property: Individual albums are available through the ``albums`` property:
.. code-block:: pycon
>>> api.photos.albums['Screenshots'] >>> api.photos.albums['Screenshots']
<PhotoAlbum: 'Screenshots'> <PhotoAlbum: 'Screenshots'>
Which you can iterate to access the photo assets. The 'All Photos' album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) : Which you can iterate to access the photo assets. The 'All Photos' album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) :
.. code-block:: pycon
>>> for photo in api.photos.albums['Screenshots']: >>> for photo in api.photos.albums['Screenshots']:
print photo, photo.filename print(photo, photo.filename)
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG <PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
To download a photo use the `download` method, which will return a `response object <http://www.python-requests.org/en/latest/api/#classes>`_, initialized with ``stream`` set to ``True``, so you can read from the raw response object: To download a photo use the `download` method, which will return a `response object <http://www.python-requests.org/en/latest/api/#classes>`_, initialized with ``stream`` set to ``True``, so you can read from the raw response object:
>>> photo = next(iter(api.photos.albums['Screenshots']), None) .. code-block:: python
>>> download = photo.download()
>>> with open(photo.filename, 'wb') as opened_file: photo = next(iter(api.photos.albums['Screenshots']), None)
download = photo.download()
with open(photo.filename, 'wb') as opened_file:
opened_file.write(download.raw.read()) opened_file.write(download.raw.read())
Note: Consider using ``shutil.copyfile`` or another buffered strategy for downloading the file so that the whole file isn't read into memory before writing. Note: Consider using ``shutil.copyfile`` or another buffered strategy for downloading the file so that the whole file isn't read into memory before writing.
Information about each version can be accessed through the ``versions`` property: Information about each version can be accessed through the ``versions`` property:
.. code-block:: pycon
>>> photo.versions.keys() >>> photo.versions.keys()
['medium', 'original', 'thumb'] ['medium', 'original', 'thumb']
To download a specific version of the photo asset, pass the version to ``download()``: To download a specific version of the photo asset, pass the version to ``download()``:
>>> download = photo.download('thumb') .. code-block:: python
>>> with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
download = photo.download('thumb')
with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
thumb_file.write(download.raw.read()) thumb_file.write(download.raw.read())

View file

@ -105,13 +105,16 @@ class PyiCloudSession(Session):
fmip_url = self.service._get_webservice_url("findme") fmip_url = self.service._get_webservice_url("findme")
if ( if (
has_retried is None has_retried is None
and response.status_code == 450 and response.status_code in [421, 450, 500]
and fmip_url in url and fmip_url in url
): ):
# Handle re-authentication for Find My iPhone # Handle re-authentication for Find My iPhone
LOGGER.debug("Re-authenticating Find My iPhone service") LOGGER.debug("Re-authenticating Find My iPhone service")
try: try:
self.service.authenticate(True, "find") # 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: except PyiCloudAPIResponseException:
LOGGER.debug("Re-authentication failed") LOGGER.debug("Re-authentication failed")
kwargs["retried"] = True kwargs["retried"] = True

View file

@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from calendar import monthrange from calendar import monthrange
from tzlocal import get_localzone from tzlocal import get_localzone_name
class CalendarService: class CalendarService:
@ -27,7 +27,7 @@ class CalendarService:
(a calendar) and a guid (an event's ID). (a calendar) and a guid (an event's ID).
""" """
params = dict(self.params) params = dict(self.params)
params.update({"lang": "en-us", "usertz": get_localzone().zone}) params.update({"lang": "en-us", "usertz": get_localzone_name()})
url = f"{self._calendar_event_detail_url}/{pguid}/{guid}" url = f"{self._calendar_event_detail_url}/{pguid}/{guid}"
req = self.session.get(url, params=params) req = self.session.get(url, params=params)
self.response = req.json() self.response = req.json()
@ -49,7 +49,7 @@ class CalendarService:
params.update( params.update(
{ {
"lang": "en-us", "lang": "en-us",
"usertz": get_localzone().zone, "usertz": get_localzone_name(),
"startDate": from_dt.strftime("%Y-%m-%d"), "startDate": from_dt.strftime("%Y-%m-%d"),
"endDate": to_dt.strftime("%Y-%m-%d"), "endDate": to_dt.strftime("%Y-%m-%d"),
} }
@ -76,7 +76,7 @@ class CalendarService:
params.update( params.update(
{ {
"lang": "en-us", "lang": "en-us",
"usertz": get_localzone().zone, "usertz": get_localzone_name(),
"startDate": from_dt.strftime("%Y-%m-%d"), "startDate": from_dt.strftime("%Y-%m-%d"),
"endDate": to_dt.strftime("%Y-%m-%d"), "endDate": to_dt.strftime("%Y-%m-%d"),
} }

View file

@ -1,6 +1,7 @@
"""Drive service.""" """Drive service."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
import logging
import io import io
import mimetypes import mimetypes
import os import os
@ -8,6 +9,11 @@ import time
from re import search from re import search
from requests import Response from requests import Response
from pyicloud.exceptions import PyiCloudAPIResponseException
LOGGER = logging.getLogger(__name__)
class DriveService: class DriveService:
"""The 'Drive' iCloud service.""" """The 'Drive' iCloud service."""
@ -42,6 +48,7 @@ class DriveService:
] ]
), ),
) )
self._raise_if_error(request)
return request.json()[0] return request.json()[0]
def get_file(self, file_id, **kwargs): def get_file(self, file_id, **kwargs):
@ -52,16 +59,22 @@ class DriveService:
self._document_root + "/ws/com.apple.CloudDocs/download/by_id", self._document_root + "/ws/com.apple.CloudDocs/download/by_id",
params=file_params, params=file_params,
) )
if not response.ok: self._raise_if_error(response)
return None response_json = response.json()
url = response.json()["data_token"]["url"] package_token = response_json.get("package_token")
return self.session.get(url, params=self.params, **kwargs) 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): def get_app_data(self):
"""Returns the app library (previously ubiquity).""" """Returns the app library (previously ubiquity)."""
request = self.session.get( request = self.session.get(
self._service_root + "/retrieveAppLibraries", params=self.params self._service_root + "/retrieveAppLibraries", params=self.params
) )
self._raise_if_error(request)
return request.json()["items"] return request.json()["items"]
def _get_upload_contentws_url(self, file_object): def _get_upload_contentws_url(self, file_object):
@ -92,8 +105,7 @@ class DriveService:
} }
), ),
) )
if not request.ok: self._raise_if_error(request)
return None
return (request.json()[0]["document_id"], request.json()[0]["url"]) return (request.json()[0]["document_id"], request.json()[0]["url"])
def _update_contentws(self, folder_id, sf_info, document_id, file_object): def _update_contentws(self, folder_id, sf_info, document_id, file_object):
@ -131,8 +143,7 @@ class DriveService:
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
data=json.dumps(data), data=json.dumps(data),
) )
if not request.ok: self._raise_if_error(request)
return None
return request.json() return request.json()
def send_file(self, folder_id, file_object): def send_file(self, folder_id, file_object):
@ -140,10 +151,8 @@ class DriveService:
document_id, content_url = self._get_upload_contentws_url(file_object) document_id, content_url = self._get_upload_contentws_url(file_object)
request = self.session.post(content_url, files={file_object.name: file_object}) request = self.session.post(content_url, files={file_object.name: file_object})
if not request.ok: self._raise_if_error(request)
return None
content_response = request.json()["singleFile"] content_response = request.json()["singleFile"]
self._update_contentws(folder_id, content_response, document_id, file_object) self._update_contentws(folder_id, content_response, document_id, file_object)
def create_folders(self, parent, name): def create_folders(self, parent, name):
@ -164,6 +173,7 @@ class DriveService:
} }
), ),
) )
self._raise_if_error(request)
return request.json() return request.json()
def rename_items(self, node_id, etag, name): def rename_items(self, node_id, etag, name):
@ -183,6 +193,7 @@ class DriveService:
} }
), ),
) )
self._raise_if_error(request)
return request.json() return request.json()
def move_items_to_trash(self, node_id, etag): def move_items_to_trash(self, node_id, etag):
@ -202,6 +213,7 @@ class DriveService:
} }
), ),
) )
self._raise_if_error(request)
return request.json() return request.json()
@property @property
@ -217,6 +229,14 @@ class DriveService:
def __getitem__(self, key): def __getitem__(self, key):
return self.root[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: class DriveNode:
"""Drive node.""" """Drive node."""

View file

@ -3,9 +3,8 @@ import json
import base64 import base64
from urllib.parse import urlencode from urllib.parse import urlencode
from datetime import datetime from datetime import datetime, timezone
from pyicloud.exceptions import PyiCloudServiceNotActivatedException from pyicloud.exceptions import PyiCloudServiceNotActivatedException
from pytz import UTC
class PhotosService: class PhotosService:
@ -526,18 +525,18 @@ class PhotoAsset:
def asset_date(self): def asset_date(self):
"""Gets the photo asset date.""" """Gets the photo asset date."""
try: try:
return datetime.fromtimestamp( return datetime.utcfromtimestamp(
self._asset_record["fields"]["assetDate"]["value"] / 1000.0, tz=UTC self._asset_record["fields"]["assetDate"]["value"] / 1000.0
) ).replace(tzinfo=timezone.utc)
except KeyError: except KeyError:
return datetime.fromtimestamp(0) return datetime.utcfromtimestamp(0).replace(tzinfo=timezone.utc)
@property @property
def added_date(self): def added_date(self):
"""Gets the photo added date.""" """Gets the photo added date."""
return datetime.fromtimestamp( return datetime.utcfromtimestamp(
self._asset_record["fields"]["addedDate"]["value"] / 1000.0, tz=UTC self._asset_record["fields"]["addedDate"]["value"] / 1000.0
) ).replace(tzinfo=timezone.utc)
@property @property
def dimensions(self): def dimensions(self):

View file

@ -4,7 +4,7 @@ import time
import uuid import uuid
import json import json
from tzlocal import get_localzone from tzlocal import get_localzone_name
class RemindersService: class RemindersService:
@ -24,7 +24,7 @@ class RemindersService:
"""Refresh data.""" """Refresh data."""
params_reminders = dict(self._params) params_reminders = dict(self._params)
params_reminders.update( params_reminders.update(
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone().zone} {"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
) )
# Open reminders # Open reminders
@ -76,7 +76,7 @@ class RemindersService:
params_reminders = dict(self._params) params_reminders = dict(self._params)
params_reminders.update( params_reminders.update(
{"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone().zone} {"clientVersion": "4.0", "lang": "en-us", "usertz": get_localzone_name()}
) )
due_dates = None due_dates = None

View file

@ -1,6 +1,6 @@
[tool.black] [tool.black]
line-length = 88 line-length = 88
target-version = ["py36", "py37", "py38", "py39", "py310"] target-version = ["py37", "py38", "py39", "py310"]
exclude = ''' exclude = '''
( (

View file

@ -2,6 +2,5 @@ requests>=2.24.0
keyring>=21.4.0 keyring>=21.4.0
keyrings.alt>=3.5.2 keyrings.alt>=3.5.2
click>=7.1.2 click>=7.1.2
tzlocal==2.1 tzlocal>=4.0
pytz>=2020.1
certifi>=2020.6.20 certifi>=2020.6.20

View file

@ -1,5 +1,4 @@
black==22.1.0 black==22.1.0
pytest pylint==2.12.2
mock
pylint>=2.6.0
pylint-strict-informational==0.1 pylint-strict-informational==0.1
pytest==7.0.1

View file

@ -4,7 +4,7 @@
from setuptools import setup, find_packages from setuptools import setup, find_packages
REPO_URL = "https://github.com/picklepete/pyicloud" REPO_URL = "https://github.com/picklepete/pyicloud"
VERSION = "0.10.2" VERSION = "1.0.0"
with open("requirements.txt") as f: with open("requirements.txt") as f:
required = f.read().splitlines() required = f.read().splitlines()
@ -22,7 +22,7 @@ setup(
maintainer="The PyiCloud Authors", maintainer="The PyiCloud Authors",
packages=find_packages(include=["pyicloud*"]), packages=find_packages(include=["pyicloud*"]),
install_requires=required, install_requires=required,
python_requires=">=3.6", python_requires=">=3.7",
license="MIT", license="MIT",
classifiers=[ classifiers=[
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@ -32,7 +32,6 @@ setup(
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",

View file

@ -1,10 +1,8 @@
"""Library tests.""" """Library tests."""
import json import json
from requests import Session, Response from requests import Response
from pyicloud import base from pyicloud import base
from pyicloud.exceptions import PyiCloudFailedLoginException
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager, AppleDevice
from .const import ( from .const import (
AUTHENTICATED_USER, AUTHENTICATED_USER,
@ -42,6 +40,7 @@ class ResponseMock(Response):
"""Mocked Response.""" """Mocked Response."""
def __init__(self, result, status_code=200, **kwargs): def __init__(self, result, status_code=200, **kwargs):
"""Set up response mock."""
Response.__init__(self) Response.__init__(self)
self.result = result self.result = result
self.status_code = status_code self.status_code = status_code
@ -50,6 +49,7 @@ class ResponseMock(Response):
@property @property
def text(self): def text(self):
"""Return text."""
return json.dumps(self.result) return json.dumps(self.result)
@ -57,6 +57,7 @@ class PyiCloudSessionMock(base.PyiCloudSession):
"""Mocked PyiCloudSession.""" """Mocked PyiCloudSession."""
def request(self, method, url, **kwargs): def request(self, method, url, **kwargs):
"""Make the request."""
params = kwargs.get("params") params = kwargs.get("params")
headers = kwargs.get("headers") headers = kwargs.get("headers")
data = json.loads(kwargs.get("data", "{}")) data = json.loads(kwargs.get("data", "{}"))
@ -169,6 +170,7 @@ class PyiCloudServiceMock(base.PyiCloudService):
client_id=None, client_id=None,
with_family=True, with_family=True,
): ):
"""Set up pyicloud service mock."""
base.PyiCloudSession = PyiCloudSessionMock base.PyiCloudSession = PyiCloudSessionMock
base.PyiCloudService.__init__( base.PyiCloudService.__init__(
self, apple_id, password, cookie_directory, verify, client_id, with_family self, apple_id, password, cookie_directory, verify, client_id, with_family

View file

@ -6,11 +6,12 @@ from .const import AUTHENTICATED_USER, VALID_PASSWORD
class AccountServiceTest(TestCase): class AccountServiceTest(TestCase):
"""Account service tests""" """Account service tests."""
service = None service = None
def setUp(self): def setUp(self):
"""Set up tests."""
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD).account self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD).account
def test_repr(self): def test_repr(self):

View file

@ -1,15 +1,16 @@
"""Cmdline tests.""" """Cmdline tests."""
from pyicloud import cmdline
from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, REQUIRES_2FA_USER, VALID_PASSWORD, VALID_2FA_CODE
from .const_findmyiphone import FMI_FAMILY_WORKING
import os import os
import pickle import pickle
import pytest
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch 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): class TestCmdline(TestCase):
"""Cmdline test cases.""" """Cmdline test cases."""
@ -17,6 +18,7 @@ class TestCmdline(TestCase):
main = None main = None
def setUp(self): def setUp(self):
"""Set up tests."""
cmdline.PyiCloudService = PyiCloudServiceMock cmdline.PyiCloudService = PyiCloudServiceMock
self.main = cmdline.main self.main = cmdline.main

View file

@ -1,16 +1,19 @@
"""Drive service tests.""" """Drive service tests."""
from unittest import TestCase from unittest import TestCase
from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, VALID_PASSWORD
import pytest import pytest
# pylint: disable=pointless-statement from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, VALID_PASSWORD
class DriveServiceTest(TestCase): class DriveServiceTest(TestCase):
"""Drive service tests""" """Drive service tests."""
service = None service = None
def setUp(self): def setUp(self):
"""Set up tests."""
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD) self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
def test_root(self): def test_root(self):
@ -39,7 +42,7 @@ class DriveServiceTest(TestCase):
def test_folder_not_exists(self): def test_folder_not_exists(self):
"""Test the /not_exists folder.""" """Test the /not_exists folder."""
with pytest.raises(KeyError, match="No child named 'not_exists' exists"): with pytest.raises(KeyError, match="No child named 'not_exists' exists"):
self.service.drive["not_exists"] self.service.drive["not_exists"] # pylint: disable=pointless-statement
def test_folder(self): def test_folder(self):
"""Test the /pyiCloud folder.""" """Test the /pyiCloud folder."""

View file

@ -1,15 +1,17 @@
"""Find My iPhone service tests.""" """Find My iPhone service tests."""
from unittest import TestCase from unittest import TestCase
from . import PyiCloudServiceMock from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, VALID_PASSWORD from .const import AUTHENTICATED_USER, VALID_PASSWORD
class FindMyiPhoneServiceTest(TestCase): class FindMyiPhoneServiceTest(TestCase):
"""Find My iPhone service tests""" """Find My iPhone service tests."""
service = None service = None
def setUp(self): def setUp(self):
"""Set up tests."""
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD) self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
def test_devices(self): def test_devices(self):

View file

@ -1,11 +1,10 @@
[tox] [tox]
envlist = py36, py37, py38, py39, py310, lint envlist = py37, py38, py39, py310, lint
skip_missing_interpreters = True skip_missing_interpreters = True
[gh-actions] [gh-actions]
python = python =
3.6: py36, lint 3.7: py37, lint
3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310