diff --git a/README.md b/README.md index dc02d7b..5ba017e 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! @@ -78,3 +108,66 @@ 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' +] +``` + +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' +``` + +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.raw.read()) +``` 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 80% rename from base.py rename to pyicloud/base.py index 10de085..c7becd5 100644 --- a/base.py +++ b/pyicloud/base.py @@ -5,7 +5,11 @@ import json import requests from exceptions import PyiCloudFailedLoginException -from services import FindMyiPhoneService, CalendarService +from services import ( + FindMyiPhoneServiceManager, + CalendarService, + UbiquityService +) class PyiCloudService(object): @@ -41,22 +45,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: @@ -100,11 +92,36 @@ 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 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'] 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/exceptions.py b/pyicloud/exceptions.py similarity index 100% rename from exceptions.py rename to pyicloud/exceptions.py diff --git a/pyicloud/services/__init__.py b/pyicloud/services/__init__.py new file mode 100644 index 0000000..fdafa36 --- /dev/null +++ b/pyicloud/services/__init__.py @@ -0,0 +1,3 @@ +from calendar import CalendarService +from findmyiphone import FindMyiPhoneServiceManager +from ubiquity import UbiquityService 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/pyicloud/services/findmyiphone.py b/pyicloud/services/findmyiphone.py new file mode 100755 index 0000000..eb30711 --- /dev/null +++ b/pyicloud/services/findmyiphone.py @@ -0,0 +1,159 @@ +import json + +from pyicloud.exceptions import PyiCloudNoDevicesException + + +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 + self._service_root = service_root + self._fmip_endpoint = '%s/fmipservice/client/web' % self._service_root + self._fmip_refresh_url = '%s/refreshClient' % self._fmip_endpoint + 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, + + 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() + + 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) + + 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.manager.refresh_client() + return self.content['location'] + + def status(self, additional=[]): + """ Returns status information for device. + + This returns only a subset of possible properties. + """ + self.manager.refresh_client() + fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name'] + fields += additional + properties = {} + for field in fields: + 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`. + """ + 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. + """ + data = json.dumps({ + 'text': text, + 'userText': True, + 'ownerNbr': number, + 'lostModeEnabled': True, + 'trackingEnabled': True, + 'device': self.content['id'], + }) + 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/pyicloud/services/ubiquity.py b/pyicloud/services/ubiquity.py new file mode 100644 index 0000000..5ba0150 --- /dev/null +++ b/pyicloud/services/ubiquity.py @@ -0,0 +1,117 @@ +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] + + 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: + 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 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] + + 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) + ) 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 diff --git a/services/__init__.py b/services/__init__.py deleted file mode 100644 index 19f1d48..0000000 --- a/services/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from calendar import CalendarService -from findmyiphone import FindMyiPhoneService diff --git a/services/findmyiphone.py b/services/findmyiphone.py deleted file mode 100755 index 72155b6..0000000 --- a/services/findmyiphone.py +++ /dev/null @@ -1,81 +0,0 @@ -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. - """ - def __init__(self, service_root, session, params): - self.session = session - self.params = params - self._service_root = service_root - self._fmip_endpoint = '%s/fmipservice/client/web' % self._service_root - self._fmip_refresh_url = '%s/refreshClient' % self._fmip_endpoint - self._fmip_sound_url = '%s/playSound' % self._fmip_endpoint - self._fmip_lost_url = '%s/lostDevice' % self._fmip_endpoint - - def refresh_client(self): - """ - Refreshes the FindMyiPhoneService endpoint, - ensuring 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.' - raise PyiCloudNoDevicesException(message) - self.user_info = self.response['userInfo'] - - def location(self): - self.refresh_client() - return self.content['location'] - - def status(self, additional=[]): - """ - The FindMyiPhoneService response is quite bloated, this method - will return a subset of the more useful properties. - """ - self.refresh_client() - fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name'] - fields += additional - properties = {} - for field in fields: - properties[field] = self.content.get(field, 'Unknown') - 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) - - def lost_device(self, number, text=None): - """ - 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, - 'ownerNbr': number, - 'lostModeEnabled': True, - 'trackingEnabled': True, - 'device': self.content['id'], - }) - self.session.post(self._fmip_lost_url, params=self.params, data=data) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b7bda01 --- /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.2', + 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 +)