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:
matrix:
python-version:
- "3.6"
- "3.7"
- "3.8"
- "3.9"

View file

@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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
with:
python-version: 3.6
python-version: 3.7
- name: Install dependencies
run: |
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:
>>> from pyicloud import PyiCloudService
>>> api = PyiCloudService('jappleseed@apple.com', 'password')
.. code-block:: python
from pyicloud import PyiCloudService
api = PyiCloudService('jappleseed@apple.com', 'password')
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown.
You can also store your password in the system keyring using the command-line tool:
>>> icloud --username=jappleseed@apple.com
ICloud Password for jappleseed@apple.com:
Save password in keyring? (y/N)
.. code-block:: console
$ icloud --username=jappleseed@apple.com
ICloud Password for jappleseed@apple.com:
Save password in keyring? (y/N)
If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for.
>>> api = PyiCloudService('jappleseed@apple.com')
.. code-block:: python
api = PyiCloudService('jappleseed@apple.com')
If you would like to delete a password stored in your system keyring, you can clear a stored password using the ``--delete-from-keyring`` command-line option:
>>> icloud --username=jappleseed@apple.com --delete-from-keyring
.. code-block:: console
$ icloud --username=jappleseed@apple.com --delete-from-keyring
**Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months.
@ -69,7 +77,7 @@ If you have enabled two-factor authentications (2FA) or `two-step authentication
.. code-block:: python
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: ")
result = api.validate_2fa_code(code)
print("Code validation result: %s" % result)
@ -87,48 +95,54 @@ 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")
elif api.requires_2sa:
import click
print "Two-step authentication required. Your trusted devices are:"
print("Two-step authentication required. Your trusted devices are:")
devices = api.trusted_devices
for i, device in enumerate(devices):
print " %s: %s" % (i, device.get('deviceName',
print(
" %s: %s" % (i, device.get('deviceName',
"SMS to %s" % device.get('phoneNumber')))
)
device = click.prompt('Which device would you like to use?', default=0)
device = devices[device]
if not api.send_verification_code(device):
print "Failed to send verification code"
print("Failed to send verification code")
sys.exit(1)
code = click.prompt('Please enter validation code')
if not api.validate_verification_code(device, code):
print "Failed to verify verification code"
print("Failed to verify verification code")
sys.exit(1)
Devices
=======
You can list which devices associated with your account by using the ``devices`` property:
>>> api.devices
{
u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
}
.. code-block:: pycon
>>> api.devices
{
'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': <AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>,
'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': <AppleDevice(MacBook Air 11": Johnny Appleseed's MacBook Air)>
}
and you can access individual devices by either their index, or their ID:
>>> api.devices[0]
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
.. code-block:: pycon
>>> api.devices[0]
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==']
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
or, as a shorthand if you have only one associated apple device, you can simply use the ``iphone`` property to access the first device associated with your account:
>>> api.iphone
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
.. code-block:: pycon
>>> api.iphone
<AppleDevice(iPhone 4S: Johnny Appleseed's iPhone)>
Note: the first device associated with your account may not necessarily be your iPhone.
@ -142,16 +156,20 @@ Location
Returns the device's last known location. The Find My iPhone app must have been installed and initialized.
>>> api.iphone.location()
{'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0}
.. code-block:: pycon
>>> api.iphone.location()
{'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0}
Status
******
The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties.
>>> api.iphone.status()
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
.. code-block:: pycon
>>> api.iphone.status()
{'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"}
If you wish to request further properties, you may do so by passing in a list of property names.
@ -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.
>>> 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.
@ -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.
>>> phone_number = '555-373-383'
>>> message = 'Thief! Return my phone immediately.'
>>> api.iphone.lost_device(phone_number, message)
.. code-block:: python
phone_number = '555-373-383'
message = 'Thief! Return my phone immediately.'
api.iphone.lost_device(phone_number, message)
Calendar
@ -184,17 +206,23 @@ Events
Returns this month's events:
>>> api.calendar.events()
.. code-block:: python
api.calendar.events()
Or, between a specific date range:
>>> from_dt = datetime(2012, 1, 1)
>>> to_dt = datetime(2012, 1, 31)
>>> api.calendar.events(from_dt, to_dt)
.. code-block:: python
from_dt = datetime(2012, 1, 1)
to_dt = datetime(2012, 1, 31)
api.calendar.events(from_dt, to_dt)
Alternatively, you may fetch a single event's details, like so:
>>> api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
.. code-block:: python
api.calendar.get_event_detail('CALENDAR', 'EVENT_ID')
Contacts
@ -202,9 +230,11 @@ Contacts
You can access your iCloud contacts/address book through the ``contacts`` property:
>>> for c in api.contacts.all():
>>> print c.get('firstName'), c.get('phones')
John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}]
.. code-block:: pycon
>>> for c in api.contacts.all():
>>> print(c.get('firstName'), c.get('phones'))
John [{'field': '+1 555-55-5555-5', 'label': 'MOBILE'}]
Note: These contacts do not include contacts federated from e.g. Facebook, only the ones stored in iCloud.
@ -214,93 +244,111 @@ File Storage (Ubiquity)
You can access documents stored in your iCloud account by using the ``files`` property's ``dir`` method:
>>> api.files.dir()
['.do-not-delete',
'.localized',
'com~apple~Notes',
'com~apple~Preview',
'com~apple~mail',
'com~apple~shoebox',
'com~apple~system~spotlight'
]
.. code-block:: pycon
>>> api.files.dir()
['.do-not-delete',
'.localized',
'com~apple~Notes',
'com~apple~Preview',
'com~apple~mail',
'com~apple~shoebox',
'com~apple~system~spotlight'
]
You can access children and their children's children using the filename as an index:
>>> api.files['com~apple~Notes']
<Folder: 'com~apple~Notes'>
>>> api.files['com~apple~Notes'].type
'folder'
>>> api.files['com~apple~Notes'].dir()
[u'Documents']
>>> api.files['com~apple~Notes']['Documents'].dir()
[u'Some Document']
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
u'Some Document'
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
datetime.datetime(2012, 9, 13, 2, 26, 17)
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
1308134
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
u'file'
.. code-block:: pycon
>>> api.files['com~apple~Notes']
<Folder: 'com~apple~Notes'>
>>> api.files['com~apple~Notes'].type
'folder'
>>> api.files['com~apple~Notes'].dir()
['Documents']
>>> api.files['com~apple~Notes']['Documents'].dir()
['Some Document']
>>> api.files['com~apple~Notes']['Documents']['Some Document'].name
'Some Document'
>>> api.files['com~apple~Notes']['Documents']['Some Document'].modified
datetime.datetime(2012, 9, 13, 2, 26, 17)
>>> api.files['com~apple~Notes']['Documents']['Some Document'].size
1308134
>>> api.files['com~apple~Notes']['Documents']['Some Document'].type
'file'
And when you have a file that you'd like to download, the ``open`` method will return a response object from which you can read the ``content``.
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
'Hello, these are the file contents'
.. code-block:: pycon
>>> api.files['com~apple~Notes']['Documents']['Some Document'].open().content
'Hello, these are the file contents'
Note: the object returned from the above ``open`` method is a `response object <http://www.python-requests.org/en/latest/api/#classes>`_ and the ``open`` method can accept any parameters you might normally use in a request using `requests <https://github.com/kennethreitz/requests>`_.
For example, if you know that the file you're opening has JSON content:
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
{'How much we love you': 'lots'}
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
'lots'
.. code-block:: pycon
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()
{'How much we love you': 'lots'}
>>> api.files['com~apple~Notes']['Documents']['information.json'].open().json()['How much we love you']
'lots'
Or, if you're downloading a particularly large file, you may want to use the ``stream`` keyword argument, and read directly from the raw response object:
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
>>> with open('downloaded_file.zip', 'wb') as opened_file:
opened_file.write(download.raw.read())
.. code-block:: pycon
>>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
>>> with open('downloaded_file.zip', 'wb') as opened_file:
opened_file.write(download.raw.read())
File Storage (iCloud Drive)
===========================
You can access your iCloud Drive using an API identical to the Ubiquity one described in the previous section, except that it is rooted at ```api.drive```:
>>> api.drive.dir()
['Holiday Photos', 'Work Files']
>>> api.drive['Holiday Photos']['2013']['Sicily'].dir()
['DSC08116.JPG', 'DSC08117.JPG']
.. code-block:: pycon
>>> drive_file = api.drive['Holiday Photos']['2013']['Sicily']['DSC08116.JPG']
>>> drive_file.name
u'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
u'file'
>>> 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:
>>> 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)
.. 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:
>>> api.drive['Holiday Photos'].mkdir('2020')
>>> api.drive['Holiday Photos']['2020'].rename('2020_copy')
>>> api.drive['Holiday Photos']['2020_copy'].delete()
.. 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:
>>> with open('Vacation.jpeg', 'rb') as file_in:
>>>> api.drive['Holiday Photos'].upload(file_in)
.. 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.
@ -310,38 +358,50 @@ Photo Library
You can access the iCloud Photo Library through the ``photos`` property.
>>> api.photos.all
<PhotoAlbum: 'All Photos'>
.. code-block:: pycon
>>> api.photos.all
<PhotoAlbum: 'All Photos'>
Individual albums are available through the ``albums`` property:
>>> api.photos.albums['Screenshots']
<PhotoAlbum: 'Screenshots'>
.. code-block:: pycon
>>> api.photos.albums['Screenshots']
<PhotoAlbum: 'Screenshots'>
Which you can iterate to access the photo assets. The 'All Photos' album is sorted by `added_date` so the most recently added photos are returned first. All other albums are sorted by `asset_date` (which represents the exif date) :
>>> for photo in api.photos.albums['Screenshots']:
print photo, photo.filename
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
.. code-block:: pycon
>>> for photo in api.photos.albums['Screenshots']:
print(photo, photo.filename)
<PhotoAsset: id=AVbLPCGkp798nTb9KZozCXtO7jds> IMG_6045.JPG
To download a photo use the `download` method, which will return a `response object <http://www.python-requests.org/en/latest/api/#classes>`_, initialized with ``stream`` set to ``True``, so you can read from the raw response object:
>>> photo = next(iter(api.photos.albums['Screenshots']), None)
>>> download = photo.download()
>>> with open(photo.filename, 'wb') as opened_file:
.. code-block:: python
photo = next(iter(api.photos.albums['Screenshots']), None)
download = photo.download()
with open(photo.filename, 'wb') as opened_file:
opened_file.write(download.raw.read())
Note: Consider using ``shutil.copyfile`` or another buffered strategy for downloading the file so that the whole file isn't read into memory before writing.
Information about each version can be accessed through the ``versions`` property:
>>> photo.versions.keys()
['medium', 'original', 'thumb']
.. code-block:: pycon
>>> photo.versions.keys()
['medium', 'original', 'thumb']
To download a specific version of the photo asset, pass the version to ``download()``:
>>> download = photo.download('thumb')
>>> with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
.. code-block:: python
download = photo.download('thumb')
with open(photo.versions['thumb']['filename'], 'wb') as thumb_file:
thumb_file.write(download.raw.read())

View file

@ -105,13 +105,16 @@ class PyiCloudSession(Session):
fmip_url = self.service._get_webservice_url("findme")
if (
has_retried is None
and response.status_code == 450
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:
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:
LOGGER.debug("Re-authentication failed")
kwargs["retried"] = True

View file

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

View file

@ -1,6 +1,7 @@
"""Drive service."""
from datetime import datetime, timedelta
import json
import logging
import io
import mimetypes
import os
@ -8,6 +9,11 @@ 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."""
@ -42,6 +48,7 @@ class DriveService:
]
),
)
self._raise_if_error(request)
return request.json()[0]
def get_file(self, file_id, **kwargs):
@ -52,16 +59,22 @@ class DriveService:
self._document_root + "/ws/com.apple.CloudDocs/download/by_id",
params=file_params,
)
if not response.ok:
return None
url = response.json()["data_token"]["url"]
return self.session.get(url, params=self.params, **kwargs)
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):
@ -92,8 +105,7 @@ class DriveService:
}
),
)
if not request.ok:
return None
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):
@ -131,8 +143,7 @@ class DriveService:
headers={"Content-Type": "text/plain"},
data=json.dumps(data),
)
if not request.ok:
return None
self._raise_if_error(request)
return request.json()
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)
request = self.session.post(content_url, files={file_object.name: file_object})
if not request.ok:
return None
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):
@ -164,6 +173,7 @@ class DriveService:
}
),
)
self._raise_if_error(request)
return request.json()
def rename_items(self, node_id, etag, name):
@ -183,6 +193,7 @@ class DriveService:
}
),
)
self._raise_if_error(request)
return request.json()
def move_items_to_trash(self, node_id, etag):
@ -202,6 +213,7 @@ class DriveService:
}
),
)
self._raise_if_error(request)
return request.json()
@property
@ -217,6 +229,14 @@ class DriveService:
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."""

View file

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

View file

@ -4,7 +4,7 @@ import time
import uuid
import json
from tzlocal import get_localzone
from tzlocal import get_localzone_name
class RemindersService:
@ -24,7 +24,7 @@ class RemindersService:
"""Refresh data."""
params_reminders = dict(self._params)
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
@ -76,7 +76,7 @@ class RemindersService:
params_reminders = dict(self._params)
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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
"""Library tests."""
import json
from requests import Session, Response
from requests import Response
from pyicloud import base
from pyicloud.exceptions import PyiCloudFailedLoginException
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager, AppleDevice
from .const import (
AUTHENTICATED_USER,
@ -42,6 +40,7 @@ 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
@ -50,6 +49,7 @@ class ResponseMock(Response):
@property
def text(self):
"""Return text."""
return json.dumps(self.result)
@ -57,6 +57,7 @@ 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", "{}"))
@ -169,6 +170,7 @@ class PyiCloudServiceMock(base.PyiCloudService):
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

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

View file

@ -1,15 +1,16 @@
"""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 pickle
import pytest
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."""
@ -17,6 +18,7 @@ class TestCmdline(TestCase):
main = None
def setUp(self):
"""Set up tests."""
cmdline.PyiCloudService = PyiCloudServiceMock
self.main = cmdline.main

View file

@ -1,16 +1,19 @@
"""Drive service tests."""
from unittest import TestCase
from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, VALID_PASSWORD
import pytest
# pylint: disable=pointless-statement
from . import PyiCloudServiceMock
from .const import AUTHENTICATED_USER, VALID_PASSWORD
class DriveServiceTest(TestCase):
"""Drive service tests"""
"""Drive service tests."""
service = None
def setUp(self):
"""Set up tests."""
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
def test_root(self):
@ -39,7 +42,7 @@ class DriveServiceTest(TestCase):
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"]
self.service.drive["not_exists"] # pylint: disable=pointless-statement
def test_folder(self):
"""Test the /pyiCloud folder."""

View file

@ -1,15 +1,17 @@
"""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"""
"""Find My iPhone service tests."""
service = None
def setUp(self):
"""Set up tests."""
self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD)
def test_devices(self):

View file

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