From 804cbf15211bbddddb9a4bc2761aeaa1724e667b Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 17:20:41 -0700 Subject: [PATCH 1/7] Rearrange package to allow for installability. --- __init__.py => pyicloud/__init__.py | 0 base.py => pyicloud/base.py | 0 exceptions.py => pyicloud/exceptions.py | 0 {services => pyicloud/services}/__init__.py | 0 {services => pyicloud/services}/calendar.py | 0 .../services}/findmyiphone.py | 0 setup.py | 20 +++++++++++++++++++ 7 files changed, 20 insertions(+) rename __init__.py => pyicloud/__init__.py (100%) rename base.py => pyicloud/base.py (100%) rename exceptions.py => pyicloud/exceptions.py (100%) rename {services => pyicloud/services}/__init__.py (100%) rename {services => pyicloud/services}/calendar.py (100%) rename {services => pyicloud/services}/findmyiphone.py (100%) create mode 100644 setup.py diff --git a/__init__.py b/pyicloud/__init__.py similarity index 100% rename from __init__.py rename to pyicloud/__init__.py diff --git a/base.py b/pyicloud/base.py similarity index 100% rename from base.py rename to pyicloud/base.py diff --git a/exceptions.py b/pyicloud/exceptions.py similarity index 100% rename from exceptions.py rename to pyicloud/exceptions.py diff --git a/services/__init__.py b/pyicloud/services/__init__.py similarity index 100% rename from services/__init__.py rename to pyicloud/services/__init__.py diff --git a/services/calendar.py b/pyicloud/services/calendar.py similarity index 100% rename from services/calendar.py rename to pyicloud/services/calendar.py diff --git a/services/findmyiphone.py b/pyicloud/services/findmyiphone.py similarity index 100% rename from services/findmyiphone.py rename to pyicloud/services/findmyiphone.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7c5394c --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup, find_packages + + +with open('requirements.txt') as f: + required = f.read().splitlines() + + +setup( + name='pyicloud', + version='0.1', + url='https://github.com/picklepete/pyicloud', + description=( + 'PyiCloud is a module which allows pythonistas to ' + 'interact with iCloud webservices.' + ), + author='Peter Evans', + author_email='evans.peter@gmail.com', + packages=find_packages(), + install_requires=required +) From ff230c6f423527a13048e95fde7e2bd02bba59d8 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 17:36:33 -0700 Subject: [PATCH 2/7] Removing clientBuildNumber/clientId from request parameters due to originating endpoint no longer existing. --- pyicloud/base.py | 14 +------------- requirements.txt | 3 +-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/pyicloud/base.py b/pyicloud/base.py index 10de085..3cea2eb 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -41,22 +41,10 @@ class PyiCloudService(object): 'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)' }) - self.refresh_version() - self.params = { - 'clientId': self.client_id, - 'clientBuildNumber': self.build_id - } + self.params = {} self.authenticate() - def refresh_version(self): - """ - Retrieves the buildNumber from the /version endpoint. - This is used by almost all request query strings. - """ - req = requests.get(self._base_system_url) - self.build_id = req.json()['buildNumber'] - def refresh_validate(self): """ Queries the /validate endpoint and fetches two key values we need: diff --git a/requirements.txt b/requirements.txt index 04f80f5..a167244 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -uuid -requests \ No newline at end of file +requests>=1.2 From 8b5e907896f24bbd6a19c00453458fcdb3ccaad2 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 19:14:27 -0700 Subject: [PATCH 3/7] Adapt existing module architecture to allow for multiple apple devices; adding unicode/string displays; adding attribute and key convenience accessors. --- pyicloud/base.py | 24 ++++- pyicloud/services/__init__.py | 2 +- pyicloud/services/findmyiphone.py | 144 +++++++++++++++++++++++------- setup.py | 2 +- 4 files changed, 134 insertions(+), 38 deletions(-) diff --git a/pyicloud/base.py b/pyicloud/base.py index 3cea2eb..3328523 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -5,7 +5,7 @@ import json import requests from exceptions import PyiCloudFailedLoginException -from services import FindMyiPhoneService, CalendarService +from services import FindMyiPhoneServiceManager, CalendarService class PyiCloudService(object): @@ -88,11 +88,29 @@ class PyiCloudService(object): self.webservices = self.discovery['webservices'] @property - def iphone(self): + def devices(self): + """ Return all devices.""" service_root = self.webservices['findme']['url'] - return FindMyiPhoneService(service_root, self.session, self.params) + return FindMyiPhoneServiceManager( + service_root, + self.session, + self.params + ) + + @property + def iphone(self): + return self.devices[0] @property def calendar(self): service_root = self.webservices['calendar']['url'] return CalendarService(service_root, self.session, self.params) + + def __unicode__(self): + return u'iCloud API: %s' % self.user.get('apple_id') + + def __str__(self): + return unicode(self).encode('ascii', 'ignore') + + def __repr__(self): + return '<%s>' % str(self) diff --git a/pyicloud/services/__init__.py b/pyicloud/services/__init__.py index 19f1d48..004018c 100644 --- a/pyicloud/services/__init__.py +++ b/pyicloud/services/__init__.py @@ -1,2 +1,2 @@ from calendar import CalendarService -from findmyiphone import FindMyiPhoneService +from findmyiphone import FindMyiPhoneServiceManager diff --git a/pyicloud/services/findmyiphone.py b/pyicloud/services/findmyiphone.py index 72155b6..eb30711 100755 --- a/pyicloud/services/findmyiphone.py +++ b/pyicloud/services/findmyiphone.py @@ -3,11 +3,14 @@ import json from pyicloud.exceptions import PyiCloudNoDevicesException -class FindMyiPhoneService(object): - """ - The 'Find my iPhone' iCloud service, connects to iCloud and returns - phone data including the near-realtime latitude and longitude. +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): self.session = session self.params = params @@ -17,59 +20,106 @@ class FindMyiPhoneService(object): self._fmip_sound_url = '%s/playSound' % self._fmip_endpoint self._fmip_lost_url = '%s/lostDevice' % self._fmip_endpoint + self._devices = {} + self.refresh_client() + def refresh_client(self): - """ - Refreshes the FindMyiPhoneService endpoint, - ensuring that the location data is up-to-date. + """ Refreshes the FindMyiPhoneService endpoint, + + This ensures that the location data is up-to-date. + """ host = self._service_root.split('//')[1].split(':')[0] self.session.headers.update({'host': host}) req = self.session.post(self._fmip_refresh_url, params=self.params) self.response = req.json() - if self.response['content']: - # TODO: Support multiple devices. - self.content = self.response['content'][0] - else: - message = 'You do not have any active devices.' + + for device_info in self.response['content']: + device_id = device_info['id'] + if not device_id in self._devices: + self._devices[device_id] = AppleDevice( + device_info, + self.session, + self.params, + manager=self, + sound_url=self._fmip_sound_url, + lost_url=self._fmip_lost_url + ) + else: + self._devices[device_id].update(device_info) + + if not self._devices: raise PyiCloudNoDevicesException(message) - self.user_info = self.response['userInfo'] + + def __getitem__(self, key): + if isinstance(key, int): + key = self.keys()[key] + return self._devices[key] + + def __getattr__(self, attr): + return getattr(self._devices, attr) + + def __unicode__(self): + return unicode(self._devices) + + def __str__(self): + return unicode(self).encode('ascii', 'ignore') + + def __repr__(self): + return str(self) + + +class AppleDevice(object): + def __init__(self, content, session, params, manager, + sound_url=None, lost_url=None): + self.content = content + self.manager = manager + self.session = session + self.params = params + + self.sound_url = sound_url + self.lost_url = lost_url + + def update(self, data): + self.content = data def location(self): - self.refresh_client() + self.manager.refresh_client() return self.content['location'] def status(self, additional=[]): + """ Returns status information for device. + + This returns only a subset of possible properties. """ - The FindMyiPhoneService response is quite bloated, this method - will return a subset of the more useful properties. - """ - self.refresh_client() + self.manager.refresh_client() fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name'] fields += additional properties = {} for field in fields: - properties[field] = self.content.get(field, 'Unknown') + properties[field] = self.content.get(field) return properties 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`. - """ - self.refresh_client() - data = json.dumps({'device': self.content['id'], 'subject': subject}) - self.session.post(self._fmip_sound_url, params=self.params, data=data) + """ Send a request to the device to play a sound. - def lost_device(self, number, text=None): + It's possible to pass a custom message by changing the `subject`. """ - Send a request to the device to trigger 'lost mode'. The - device will show the message in `text`, and if a number has + data = json.dumps({'device': self.content['id'], 'subject': subject}) + self.session.post( + self.sound_url, + params=self.params, + data=data + ) + + def lost_device(self, number, + text='This iPhone has been lost. Please call me.'): + """ Send a request to the device to trigger 'lost mode'. + + The device will show the message in `text`, and if a number has been passed, then the person holding the device can call the number without entering the passcode. """ - self.refresh_client() - if not text: - text = 'This iPhone has been lost. Please call me.' data = json.dumps({ 'text': text, 'userText': True, @@ -78,4 +128,32 @@ class FindMyiPhoneService(object): 'trackingEnabled': True, 'device': self.content['id'], }) - self.session.post(self._fmip_lost_url, params=self.params, data=data) + self.session.post( + self.lost_url, + params=self.params, + data=data + ) + + @property + def data(self): + return self.content + + def __getitem__(self, key): + return self.content[key] + + def __getattr__(self, attr): + return getattr(self.content, attr) + + def __unicode__(self): + display_name = self['deviceDisplayName'] + name = self['name'] + return u'%s: %s' % ( + display_name, + name, + ) + + def __str__(self): + return unicode(self).encode('ascii', 'ignore') + + def __repr__(self): + return '' % str(self) diff --git a/setup.py b/setup.py index 7c5394c..b7bda01 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open('requirements.txt') as f: setup( name='pyicloud', - version='0.1', + version='0.2', url='https://github.com/picklepete/pyicloud', description=( 'PyiCloud is a module which allows pythonistas to ' From 5d3b2e3e9b3cef7120c11dab3afcfb1502c09700 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 19:32:54 -0700 Subject: [PATCH 4/7] Updating documentation to reflect new API additions. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index dc02d7b..4244446 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,36 @@ Authentication is as simple as passing your username and password to the `PyiClo In the event that the username/password combination is invalid, a `PyiCloudFailedLoginException` exception is thrown. +### Devices + +You can list which devices associated with your account by using the `devices` property: + +```python +>>> api.devices +{ +u'i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w==': , +u'reGYDh9XwqNWTGIhNBuEwP1ds0F/Lg5t/fxNbI4V939hhXawByErk+HYVNSUzmWV': +} +``` + +and you can access individual devices by either their index, or their ID: + +```python +>>> api.devices[0] + +>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] + +``` + +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: + +```python +>>> api.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! From 1d45ad9e7dc5d4c13278271104778ba64518f09c Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 21:23:21 -0700 Subject: [PATCH 5/7] Adding support for ubiquity (file synchronization/storage) API. Borrowed many implementation details from @matin's lovely icloud implementation; thanks @matin! --- README.md | 37 ++++++++++++ pyicloud/base.py | 13 ++++- pyicloud/services/__init__.py | 1 + pyicloud/services/ubiquity.py | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 pyicloud/services/ubiquity.py diff --git a/README.md b/README.md index 4244446..1850d74 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,40 @@ from_dt = datetime(2012, 1, 1) to_dt = datetime(2012, 1, 31) api.calendar.events(from_dt, to_dt) ``` + +### File Storage (Ubiquity) + +You can access documents stored in your iCloud account by using the `files` property's `dir` method: + +```python +>>> 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' +] +``` + +And, you can access children and their children's children using the filename as an index: + +```python +>>> api.files['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' +``` diff --git a/pyicloud/base.py b/pyicloud/base.py index 3328523..c7becd5 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -5,7 +5,11 @@ import json import requests from exceptions import PyiCloudFailedLoginException -from services import FindMyiPhoneServiceManager, CalendarService +from services import ( + FindMyiPhoneServiceManager, + CalendarService, + UbiquityService +) class PyiCloudService(object): @@ -101,6 +105,13 @@ class PyiCloudService(object): def iphone(self): return self.devices[0] + @property + def files(self): + if not hasattr(self, '_files'): + service_root = self.webservices['ubiquity']['url'] + self._files = UbiquityService(service_root, self.session, self.params) + return self._files + @property def calendar(self): service_root = self.webservices['calendar']['url'] diff --git a/pyicloud/services/__init__.py b/pyicloud/services/__init__.py index 004018c..fdafa36 100644 --- a/pyicloud/services/__init__.py +++ b/pyicloud/services/__init__.py @@ -1,2 +1,3 @@ from calendar import CalendarService from findmyiphone import FindMyiPhoneServiceManager +from ubiquity import UbiquityService diff --git a/pyicloud/services/ubiquity.py b/pyicloud/services/ubiquity.py new file mode 100644 index 0000000..5fce273 --- /dev/null +++ b/pyicloud/services/ubiquity.py @@ -0,0 +1,107 @@ +from datetime import datetime + + +class UbiquityService(object): + """ The 'Ubiquity' iCloud service.""" + + def __init__(self, service_root, session, params): + self.session = session + self.params = params + self._root = None + + self._service_root = service_root + self._node_url = '/ws/%s/%s/%s' + + host = self._service_root.split('//')[1].split(':')[0] + self.session.headers.update({'host': host}) + + 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] + + @property + def root(self): + if not self._root: + self._root = self.get_node(0) + return self._root + + def __getattr__(self, attr): + return getattr(self.root, attr) + + def __getitem__(self, key): + return self.root[key] + + +class UbiquityNode(object): + def __init__(self, conn, data): + self.data = data + self.connection = conn + + @property + def item_id(self): + return self.data.get('item_id') + + @property + def name(self): + return self.data.get('name') + + @property + def type(self): + return self.data.get('type') + + def get_children(self): + if not hasattr(self, '_children'): + self._children = self.connection.get_children(self.item_id) + return self._children + + @property + def size(self): + try: + return int(self.data.get('size')) + except ValueError: + return None + + @property + def modified(self): + return datetime.strptime( + self.data.get('modified'), + '%Y-%m-%dT%H:%M:%SZ' + ) + + def dir(self): + return [child.name for child in self.get_children()] + + def get(self, name): + return [child for child in self.get_children() if child.name == name][0] + + def __getitem__(self, key): + try: + return self.get(key) + except IndexError: + raise KeyError('No child named %s exists' % key) + + def __unicode__(self): + return self.name + + def __str__(self): + return self.name.encode('unicode-escape') + + def __repr__(self): + return "<%s: u'%s'>" % ( + self.type.capitalize(), + str(self) + ) From 870d9bc7e923e67c09bdbcb32f4a56c48d4d5525 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 22:35:52 -0700 Subject: [PATCH 6/7] Adding support for downloading files from iCloud's ubiquity service. --- README.md | 28 +++++++++++++++++++++++++++- pyicloud/services/ubiquity.py | 10 ++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1850d74..cac6ebe 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ You can access documents stored in your iCloud account by using the `files` prop ] ``` -And, 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: ```python >>> api.files['com~apple~Notes'] @@ -145,3 +145,29 @@ datetime.datetime(2012, 9, 13, 2, 26, 17) >>> 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`. + +```python +>>> 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: + +```python +>>> 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: + +```python +>>> 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.read()) +``` diff --git a/pyicloud/services/ubiquity.py b/pyicloud/services/ubiquity.py index 5fce273..5ba0150 100644 --- a/pyicloud/services/ubiquity.py +++ b/pyicloud/services/ubiquity.py @@ -33,6 +33,13 @@ class UbiquityService(object): 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): if not self._root: @@ -85,6 +92,9 @@ class UbiquityNode(object): def dir(self): 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): return [child for child in self.get_children() if child.name == name][0] From 6c801a50a4037a6a87adc021dffa0e99b3921e8f Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sat, 18 May 2013 22:50:17 -0700 Subject: [PATCH 7/7] Correcting documentation; to access the raw response object, you must use 'raw'. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cac6ebe..5ba017e 100644 --- a/README.md +++ b/README.md @@ -169,5 +169,5 @@ Or, if you're downloading a particularly large file, you may want to use the `st ```python >>> 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.read()) + opened_file.write(download.raw.read()) ```