Replace PEP8 by pylint (#257)
This commit is contained in:
parent
a6358630e3
commit
1090393774
18 changed files with 295 additions and 190 deletions
|
@ -14,5 +14,5 @@ before_install:
|
|||
- pip install -r requirements_all.txt
|
||||
- pip install -e .
|
||||
script:
|
||||
- pep8 pyicloud
|
||||
- pylint pyicloud tests
|
||||
- py.test
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""The pyiCloud library."""
|
||||
import logging
|
||||
from pyicloud.base import PyiCloudService
|
||||
|
||||
|
|
119
pyicloud/base.py
119
pyicloud/base.py
|
@ -1,10 +1,10 @@
|
|||
"""Library base file."""
|
||||
import six
|
||||
import uuid
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests import Session
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
|
@ -30,40 +30,42 @@ from pyicloud.utils import get_password_from_keyring
|
|||
if six.PY3:
|
||||
import http.cookiejar as cookielib
|
||||
else:
|
||||
import cookielib
|
||||
import cookielib # pylint: disable=import-error
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyiCloudPasswordFilter(logging.Filter):
|
||||
"""Password log hider."""
|
||||
def __init__(self, password):
|
||||
self.password = password
|
||||
super(PyiCloudPasswordFilter, self).__init__(password)
|
||||
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
if self.password in message:
|
||||
record.msg = message.replace(self.password, "*" * 8)
|
||||
if self.name in message:
|
||||
record.msg = message.replace(self.name, "*" * 8)
|
||||
record.args = []
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PyiCloudSession(requests.Session):
|
||||
class PyiCloudSession(Session):
|
||||
"""iCloud session."""
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
super(PyiCloudSession, self).__init__()
|
||||
|
||||
def request(self, *args, **kwargs):
|
||||
def request(self, *args, **kwargs): # pylint: disable=arguments-differ
|
||||
|
||||
# Charge logging to the right service endpoint
|
||||
callee = inspect.stack()[2]
|
||||
module = inspect.getmodule(callee[0])
|
||||
logger = logging.getLogger(module.__name__).getChild('http')
|
||||
if self.service._password_filter not in logger.filters:
|
||||
logger.addFilter(self.service._password_filter)
|
||||
request_logger = logging.getLogger(module.__name__).getChild('http')
|
||||
if self.service.password_filter not in request_logger.filters:
|
||||
request_logger.addFilter(self.service.password_filter)
|
||||
|
||||
logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', ''))
|
||||
request_logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', ''))
|
||||
|
||||
kwargs.pop('retried', None)
|
||||
response = super(PyiCloudSession, self).request(*args, **kwargs)
|
||||
|
@ -78,7 +80,7 @@ class PyiCloudSession(requests.Session):
|
|||
response.status_code,
|
||||
retry=True
|
||||
)
|
||||
logger.warn(api_error)
|
||||
request_logger.warn(api_error)
|
||||
kwargs['retried'] = True
|
||||
return self.request(*args, **kwargs)
|
||||
self._raise_error(response.status_code, response.reason)
|
||||
|
@ -87,24 +89,24 @@ class PyiCloudSession(requests.Session):
|
|||
return response
|
||||
|
||||
try:
|
||||
json = response.json()
|
||||
except:
|
||||
logger.warning('Failed to parse response with JSON mimetype')
|
||||
data = response.json()
|
||||
except: # pylint: disable=bare-except
|
||||
request_logger.warning('Failed to parse response with JSON mimetype')
|
||||
return response
|
||||
|
||||
logger.debug(json)
|
||||
request_logger.debug(data)
|
||||
|
||||
reason = json.get('errorMessage')
|
||||
reason = reason or json.get('reason')
|
||||
reason = reason or json.get('errorReason')
|
||||
if not reason and isinstance(json.get('error'), six.string_types):
|
||||
reason = json.get('error')
|
||||
if not reason and json.get('error'):
|
||||
reason = data.get('errorMessage')
|
||||
reason = reason or data.get('reason')
|
||||
reason = reason or data.get('errorReason')
|
||||
if not reason and isinstance(data.get('error'), six.string_types):
|
||||
reason = data.get('error')
|
||||
if not reason and data.get('error'):
|
||||
reason = "Unknown reason"
|
||||
|
||||
code = json.get('errorCode')
|
||||
if not code and json.get('serverErrorCode'):
|
||||
code = json.get('serverErrorCode')
|
||||
code = data.get('errorCode')
|
||||
if not code and data.get('serverErrorCode'):
|
||||
code = data.get('serverErrorCode')
|
||||
|
||||
if reason:
|
||||
self._raise_error(code, reason)
|
||||
|
@ -115,11 +117,11 @@ class PyiCloudSession(requests.Session):
|
|||
if self.service.requires_2sa and \
|
||||
reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie':
|
||||
raise PyiCloud2SARequiredException(self.service.user['apple_id'])
|
||||
if code == 'ZONE_NOT_FOUND' or code == 'AUTHENTICATION_FAILED':
|
||||
if code in ('ZONE_NOT_FOUND', 'AUTHENTICATION_FAILED'):
|
||||
reason = 'Please log into https://icloud.com/ to manually ' \
|
||||
'finish setting up your iCloud service'
|
||||
api_error = PyiCloudServiceNotActivatedException(reason, code)
|
||||
logger.error(api_error)
|
||||
LOGGER.error(api_error)
|
||||
|
||||
raise(api_error)
|
||||
if code == 'ACCESS_DENIED':
|
||||
|
@ -128,7 +130,7 @@ class PyiCloudSession(requests.Session):
|
|||
'throttle requests.'
|
||||
|
||||
api_error = PyiCloudAPIResponseException(reason, code)
|
||||
logger.error(api_error)
|
||||
LOGGER.error(api_error)
|
||||
raise api_error
|
||||
|
||||
|
||||
|
@ -155,8 +157,8 @@ class PyiCloudService(object):
|
|||
self.with_family = with_family
|
||||
self.user = {'apple_id': apple_id, 'password': password}
|
||||
|
||||
self._password_filter = PyiCloudPasswordFilter(password)
|
||||
logger.addFilter(self._password_filter)
|
||||
self.password_filter = PyiCloudPasswordFilter(password)
|
||||
LOGGER.addFilter(self.password_filter)
|
||||
|
||||
self._home_endpoint = 'https://www.icloud.com'
|
||||
self._setup_endpoint = 'https://setup.icloud.com/setup/ws/1'
|
||||
|
@ -186,12 +188,12 @@ class PyiCloudService(object):
|
|||
if os.path.exists(cookiejar_path):
|
||||
try:
|
||||
self.session.cookies.load()
|
||||
logger.debug("Read cookies from %s", cookiejar_path)
|
||||
except:
|
||||
LOGGER.debug("Read cookies from %s", cookiejar_path)
|
||||
except: # pylint: disable=bare-except
|
||||
# Most likely a pickled cookiejar from earlier versions.
|
||||
# The cookiejar will get replaced with a valid one after
|
||||
# successful authentication.
|
||||
logger.warning("Failed to read cookiejar %s", cookiejar_path)
|
||||
LOGGER.warning("Failed to read cookiejar %s", cookiejar_path)
|
||||
|
||||
self.params = {
|
||||
'clientBuildNumber': '17DHotfix5',
|
||||
|
@ -203,13 +205,16 @@ class PyiCloudService(object):
|
|||
|
||||
self.authenticate()
|
||||
|
||||
self._files = None
|
||||
self._photos = None
|
||||
|
||||
def authenticate(self):
|
||||
"""
|
||||
Handles authentication, and persists the X-APPLE-WEB-KB cookie so that
|
||||
subsequent logins will not cause additional e-mails from Apple.
|
||||
"""
|
||||
|
||||
logger.info("Authenticating as %s", self.user['apple_id'])
|
||||
LOGGER.info("Authenticating as %s", self.user['apple_id'])
|
||||
|
||||
data = dict(self.user)
|
||||
|
||||
|
@ -226,22 +231,20 @@ class PyiCloudService(object):
|
|||
msg = 'Invalid email/password combination.'
|
||||
raise PyiCloudFailedLoginException(msg, error)
|
||||
|
||||
resp = req.json()
|
||||
self.params.update({'dsid': resp['dsInfo']['dsid']})
|
||||
self.data = req.json()
|
||||
self.params.update({'dsid': self.data['dsInfo']['dsid']})
|
||||
self._webservices = self.data['webservices']
|
||||
|
||||
if not os.path.exists(self._cookie_directory):
|
||||
os.mkdir(self._cookie_directory)
|
||||
self.session.cookies.save()
|
||||
logger.debug("Cookies saved to %s", self._get_cookiejar_path())
|
||||
LOGGER.debug("Cookies saved to %s", self._get_cookiejar_path())
|
||||
|
||||
self.data = resp
|
||||
self._webservices = self.data['webservices']
|
||||
|
||||
logger.info("Authentication completed successfully")
|
||||
logger.debug(self.params)
|
||||
LOGGER.info("Authentication completed successfully")
|
||||
LOGGER.debug(self.params)
|
||||
|
||||
def _get_cookiejar_path(self):
|
||||
# Get path for cookiejar file
|
||||
"""Get path for cookiejar file."""
|
||||
return os.path.join(
|
||||
self._cookie_directory,
|
||||
''.join([c for c in self.user.get('apple_id') if match(r'\w', c)])
|
||||
|
@ -252,7 +255,7 @@ class PyiCloudService(object):
|
|||
"""Returns True if two-step authentication is required."""
|
||||
return self.data.get('hsaChallengeRequired', False) \
|
||||
and self.data['dsInfo'].get('hsaVersion', 0) >= 1
|
||||
# FIXME: Implement 2FA for hsaVersion == 2
|
||||
# FIXME: Implement 2FA for hsaVersion == 2 # pylint: disable=fixme
|
||||
|
||||
@property
|
||||
def trusted_devices(self):
|
||||
|
@ -282,7 +285,7 @@ class PyiCloudService(object):
|
|||
data = json.dumps(device)
|
||||
|
||||
try:
|
||||
request = self.session.post(
|
||||
self.session.post(
|
||||
'%s/validateVerificationCode' % self._setup_endpoint,
|
||||
params=self.params,
|
||||
data=data
|
||||
|
@ -310,7 +313,7 @@ class PyiCloudService(object):
|
|||
|
||||
@property
|
||||
def devices(self):
|
||||
"""Return all devices."""
|
||||
"""Returns all devices."""
|
||||
service_root = self._get_webservice_url('findme')
|
||||
return FindMyiPhoneServiceManager(
|
||||
service_root,
|
||||
|
@ -319,8 +322,14 @@ class PyiCloudService(object):
|
|||
self.with_family
|
||||
)
|
||||
|
||||
@property
|
||||
def iphone(self):
|
||||
"""Returns the iPhone."""
|
||||
return self.devices[0]
|
||||
|
||||
@property
|
||||
def account(self):
|
||||
"""Gets the 'Account' service."""
|
||||
service_root = self._get_webservice_url('account')
|
||||
return AccountService(
|
||||
service_root,
|
||||
|
@ -328,13 +337,10 @@ class PyiCloudService(object):
|
|||
self.params
|
||||
)
|
||||
|
||||
@property
|
||||
def iphone(self):
|
||||
return self.devices[0]
|
||||
|
||||
@property
|
||||
def files(self):
|
||||
if not hasattr(self, '_files'):
|
||||
"""Gets the 'File' service."""
|
||||
if not self._files:
|
||||
service_root = self._get_webservice_url('ubiquity')
|
||||
self._files = UbiquityService(
|
||||
service_root,
|
||||
|
@ -345,7 +351,8 @@ class PyiCloudService(object):
|
|||
|
||||
@property
|
||||
def photos(self):
|
||||
if not hasattr(self, '_photos'):
|
||||
"""Gets the 'Photo' service."""
|
||||
if not self._photos:
|
||||
service_root = self._get_webservice_url('ckdatabasews')
|
||||
self._photos = PhotosService(
|
||||
service_root,
|
||||
|
@ -356,16 +363,19 @@ class PyiCloudService(object):
|
|||
|
||||
@property
|
||||
def calendar(self):
|
||||
"""Gets the 'Calendar' service."""
|
||||
service_root = self._get_webservice_url('calendar')
|
||||
return CalendarService(service_root, self.session, self.params)
|
||||
|
||||
@property
|
||||
def contacts(self):
|
||||
"""Gets the 'Contacts' service."""
|
||||
service_root = self._get_webservice_url('contacts')
|
||||
return ContactsService(service_root, self.session, self.params)
|
||||
|
||||
@property
|
||||
def reminders(self):
|
||||
"""Gets the 'Reminders' service."""
|
||||
service_root = self._get_webservice_url('reminders')
|
||||
return RemindersService(service_root, self.session, self.params)
|
||||
|
||||
|
@ -376,7 +386,6 @@ class PyiCloudService(object):
|
|||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
|
||||
def __repr__(self):
|
||||
|
|
|
@ -9,7 +9,7 @@ import argparse
|
|||
import pickle
|
||||
import sys
|
||||
|
||||
from click import confirm
|
||||
from click import confirm, prompt
|
||||
|
||||
import pyicloud
|
||||
from . import utils
|
||||
|
@ -26,12 +26,9 @@ def create_pickled_data(idevice, filename):
|
|||
|
||||
This allows the data to be used without resorting to screen / pipe
|
||||
scrapping."""
|
||||
data = {}
|
||||
for x in idevice.content:
|
||||
data[x] = idevice.content[x]
|
||||
location = filename
|
||||
pickle_file = open(location, 'wb')
|
||||
pickle.dump(data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
pickle_file.close()
|
||||
|
||||
|
||||
|
@ -209,7 +206,6 @@ def main(args=None):
|
|||
utils.store_password_in_keyring(username, password)
|
||||
|
||||
if api.requires_2sa:
|
||||
import click
|
||||
print("Two-step authentication required.",
|
||||
"Your trusted devices are:")
|
||||
|
||||
|
@ -220,14 +216,14 @@ def main(args=None):
|
|||
'deviceName',
|
||||
"SMS to %s" % device.get('phoneNumber'))))
|
||||
|
||||
device = click.prompt('Which device would you like to use?',
|
||||
device = prompt('Which device would you like to use?',
|
||||
default=0)
|
||||
device = devices[device]
|
||||
if not api.send_verification_code(device):
|
||||
print("Failed to send verification code")
|
||||
sys.exit(1)
|
||||
|
||||
code = click.prompt('Please enter validation code')
|
||||
code = prompt('Please enter validation code')
|
||||
if not api.validate_verification_code(device, code):
|
||||
print("Failed to verify verification code")
|
||||
sys.exit(1)
|
||||
|
@ -274,8 +270,8 @@ def main(args=None):
|
|||
if command_line.longlist:
|
||||
print("-"*30)
|
||||
print(contents["name"])
|
||||
for x in contents:
|
||||
print("%20s - %s" % (x, contents[x]))
|
||||
for key in contents:
|
||||
print("%20s - %s" % (key, contents[key]))
|
||||
elif command_line.list:
|
||||
print("-"*30)
|
||||
print("Name - %s" % contents["name"])
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
|
||||
"""Library exceptions."""
|
||||
class PyiCloudException(Exception):
|
||||
"""Generic iCloud exception."""
|
||||
pass
|
||||
|
||||
|
||||
# API
|
||||
class PyiCloudAPIResponseException(PyiCloudException):
|
||||
"""iCloud response exception."""
|
||||
def __init__(self, reason, code=None, retry=False):
|
||||
self.reason = reason
|
||||
self.code = code
|
||||
|
@ -18,24 +20,29 @@ class PyiCloudAPIResponseException(PyiCloudException):
|
|||
|
||||
|
||||
class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException):
|
||||
"""iCloud service not activated exception."""
|
||||
pass
|
||||
|
||||
|
||||
# Login
|
||||
class PyiCloudFailedLoginException(PyiCloudException):
|
||||
"""iCloud failed login exception."""
|
||||
pass
|
||||
|
||||
|
||||
class PyiCloud2SARequiredException(PyiCloudException):
|
||||
"""iCloud 2SA required exception."""
|
||||
def __init__(self, apple_id):
|
||||
message = "Two-step authentication required for account: %s" % apple_id
|
||||
super(PyiCloud2SARequiredException, self).__init__(message)
|
||||
|
||||
|
||||
class PyiCloudNoStoredPasswordAvailableException(PyiCloudException):
|
||||
"""iCloud no stored password exception."""
|
||||
pass
|
||||
|
||||
|
||||
# Webservice specific
|
||||
class PyiCloudNoDevicesException(PyiCloudException):
|
||||
"""iCloud no device exception."""
|
||||
pass
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Services."""
|
||||
from pyicloud.services.calendar import CalendarService
|
||||
from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
|
||||
from pyicloud.services.ubiquity import UbiquityService
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Account service."""
|
||||
import sys
|
||||
|
||||
import six
|
||||
|
@ -6,6 +7,7 @@ from pyicloud.utils import underscore_to_camelcase
|
|||
|
||||
|
||||
class AccountService(object):
|
||||
"""The 'Account' iCloud service."""
|
||||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
|
@ -25,14 +27,13 @@ class AccountService(object):
|
|||
|
||||
@property
|
||||
def devices(self):
|
||||
"""Gets the account devices."""
|
||||
return self._devices
|
||||
|
||||
|
||||
@six.python_2_unicode_compatible
|
||||
class AccountDevice(dict):
|
||||
def __init__(self, device_info):
|
||||
super(AccountDevice, self).__init__(device_info)
|
||||
|
||||
"""Account device."""
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self[underscore_to_camelcase(name)]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""Calendar service."""
|
||||
from __future__ import absolute_import
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from calendar import monthrange
|
||||
import time
|
||||
|
||||
from tzlocal import get_localzone
|
||||
|
||||
|
@ -21,6 +21,8 @@ class CalendarService(object):
|
|||
)
|
||||
self._calendars = '%s/startup' % self._calendar_endpoint
|
||||
|
||||
self.response = {}
|
||||
|
||||
def get_event_detail(self, pguid, guid):
|
||||
"""
|
||||
Fetches a single event's details by specifying a pguid
|
||||
|
@ -64,7 +66,7 @@ class CalendarService(object):
|
|||
|
||||
def calendars(self):
|
||||
"""
|
||||
Retrieves calendars for this month
|
||||
Retrieves calendars of this month.
|
||||
"""
|
||||
today = datetime.today()
|
||||
first_day, last_day = monthrange(today.year, today.month)
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
"""Contacts service."""
|
||||
from __future__ import absolute_import
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from calendar import monthrange
|
||||
|
||||
|
||||
class ContactsService(object):
|
||||
|
@ -19,6 +16,8 @@ class ContactsService(object):
|
|||
self._contacts_next_url = '%s/contacts' % self._contacts_endpoint
|
||||
self._contacts_changeset_url = '%s/changeset' % self._contacts_endpoint
|
||||
|
||||
self.response = {}
|
||||
|
||||
def refresh_client(self):
|
||||
"""
|
||||
Refreshes the ContactsService endpoint, ensuring that the
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Find my iPhone service."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
@ -11,7 +12,6 @@ class FindMyiPhoneServiceManager(object):
|
|||
|
||||
This connects to iCloud and return phone data including the near-realtime
|
||||
latitude and longitude.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, service_root, session, params, with_family=False):
|
||||
|
@ -85,7 +85,6 @@ class FindMyiPhoneServiceManager(object):
|
|||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
|
||||
def __repr__(self):
|
||||
|
@ -93,6 +92,7 @@ class FindMyiPhoneServiceManager(object):
|
|||
|
||||
|
||||
class AppleDevice(object):
|
||||
"""Apple device."""
|
||||
def __init__(
|
||||
self, content, session, params, manager,
|
||||
sound_url=None, lost_url=None, message_url=None
|
||||
|
@ -107,13 +107,15 @@ class AppleDevice(object):
|
|||
self.message_url = message_url
|
||||
|
||||
def update(self, data):
|
||||
"""Updates the device data."""
|
||||
self.content = data
|
||||
|
||||
def location(self):
|
||||
"""Updates the device location."""
|
||||
self.manager.refresh_client()
|
||||
return self.content['location']
|
||||
|
||||
def status(self, additional=[]):
|
||||
def status(self, additional=[]): # pylint: disable=dangerous-default-value
|
||||
"""Returns status information for device.
|
||||
|
||||
This returns only a subset of possible properties.
|
||||
|
@ -195,6 +197,7 @@ class AppleDevice(object):
|
|||
|
||||
@property
|
||||
def data(self):
|
||||
"""Gets the device data."""
|
||||
return self.content
|
||||
|
||||
def __getitem__(self, key):
|
||||
|
@ -215,7 +218,6 @@ class AppleDevice(object):
|
|||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
|
||||
def __repr__(self):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""Photo service."""
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
|
||||
from datetime import datetime
|
||||
|
@ -9,8 +9,6 @@ from pytz import UTC
|
|||
|
||||
from future.moves.urllib.parse import urlencode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PhotosService(object):
|
||||
"""The 'Photos' iCloud service."""
|
||||
|
@ -136,7 +134,7 @@ class PhotosService(object):
|
|||
self.session = session
|
||||
self.params = dict(params)
|
||||
self._service_root = service_root
|
||||
self._service_endpoint = \
|
||||
self.service_endpoint = \
|
||||
('%s/database/1/com.apple.photos.cloud/production/private'
|
||||
% self._service_root)
|
||||
|
||||
|
@ -148,7 +146,7 @@ class PhotosService(object):
|
|||
})
|
||||
|
||||
url = ('%s/records/query?%s' %
|
||||
(self._service_endpoint, urlencode(self.params)))
|
||||
(self.service_endpoint, urlencode(self.params)))
|
||||
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
||||
request = self.session.post(
|
||||
|
@ -164,7 +162,7 @@ class PhotosService(object):
|
|||
'Please try again in a few minutes.'
|
||||
)
|
||||
|
||||
# TODO: Does syncToken ever change?
|
||||
# TODO: Does syncToken ever change? # pylint: disable=fixme
|
||||
# self.params.update({
|
||||
# 'syncToken': response['syncToken'],
|
||||
# 'clientInstanceId': self.params.pop('clientId')
|
||||
|
@ -174,12 +172,13 @@ class PhotosService(object):
|
|||
|
||||
@property
|
||||
def albums(self):
|
||||
"""Returns photo albums."""
|
||||
if not self._albums:
|
||||
self._albums = {name: PhotoAlbum(self, name, **props)
|
||||
for (name, props) in self.SMART_FOLDERS.items()}
|
||||
|
||||
for folder in self._fetch_folders():
|
||||
# FIXME: Handle subfolders
|
||||
# TODO: Handle subfolders # pylint: disable=fixme
|
||||
if folder['recordName'] == '----Root-Folder----' or \
|
||||
(folder['fields'].get('isDeleted') and
|
||||
folder['fields']['isDeleted']['value']):
|
||||
|
@ -208,7 +207,7 @@ class PhotosService(object):
|
|||
|
||||
def _fetch_folders(self):
|
||||
url = ('%s/records/query?%s' %
|
||||
(self._service_endpoint, urlencode(self.params)))
|
||||
(self.service_endpoint, urlencode(self.params)))
|
||||
json_data = ('{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
||||
|
||||
|
@ -223,10 +222,12 @@ class PhotosService(object):
|
|||
|
||||
@property
|
||||
def all(self):
|
||||
"""Returns all photos."""
|
||||
return self.albums['All Photos']
|
||||
|
||||
|
||||
class PhotoAlbum(object):
|
||||
"""A photo album."""
|
||||
|
||||
def __init__(self, service, name, list_type, obj_type, direction,
|
||||
query_filter=None, page_size=100):
|
||||
|
@ -242,6 +243,7 @@ class PhotoAlbum(object):
|
|||
|
||||
@property
|
||||
def title(self):
|
||||
"""Gets the album name."""
|
||||
return self.name
|
||||
|
||||
def __iter__(self):
|
||||
|
@ -250,11 +252,34 @@ class PhotoAlbum(object):
|
|||
def __len__(self):
|
||||
if self._len is None:
|
||||
url = ('%s/internal/records/query/batch?%s' %
|
||||
(self.service._service_endpoint,
|
||||
(self.service.service_endpoint,
|
||||
urlencode(self.service.params)))
|
||||
request = self.service.session.post(
|
||||
url,
|
||||
data=json.dumps(self._count_query_gen(self.obj_type)),
|
||||
data=json.dumps(
|
||||
{
|
||||
u'batch': [{
|
||||
u'resultsLimit': 1,
|
||||
u'query': {
|
||||
u'filterBy': {
|
||||
u'fieldName': u'indexCountID',
|
||||
u'fieldValue': {
|
||||
u'type': u'STRING_LIST',
|
||||
u'value': [
|
||||
self.obj_type
|
||||
]
|
||||
},
|
||||
u'comparator': u'IN'
|
||||
},
|
||||
u'recordType': u'HyperionIndexCountLookup'
|
||||
},
|
||||
u'zoneWide': True,
|
||||
u'zoneID': {
|
||||
u'zoneName': u'PrimarySync'
|
||||
}
|
||||
}]
|
||||
}
|
||||
),
|
||||
headers={'Content-type': 'text/plain'}
|
||||
)
|
||||
response = request.json()
|
||||
|
@ -266,13 +291,14 @@ class PhotoAlbum(object):
|
|||
|
||||
@property
|
||||
def photos(self):
|
||||
"""Returns the album photos."""
|
||||
if self.direction == "DESCENDING":
|
||||
offset = len(self) - 1
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
while(True):
|
||||
url = ('%s/records/query?' % self.service._service_endpoint) + \
|
||||
url = ('%s/records/query?' % self.service.service_endpoint) + \
|
||||
urlencode(self.service.params)
|
||||
request = self.service.session.post(
|
||||
url,
|
||||
|
@ -307,32 +333,6 @@ class PhotoAlbum(object):
|
|||
else:
|
||||
break
|
||||
|
||||
def _count_query_gen(self, obj_type):
|
||||
query = {
|
||||
u'batch': [{
|
||||
u'resultsLimit': 1,
|
||||
u'query': {
|
||||
u'filterBy': {
|
||||
u'fieldName': u'indexCountID',
|
||||
u'fieldValue': {
|
||||
u'type': u'STRING_LIST',
|
||||
u'value': [
|
||||
obj_type
|
||||
]
|
||||
},
|
||||
u'comparator': u'IN'
|
||||
},
|
||||
u'recordType': u'HyperionIndexCountLookup'
|
||||
},
|
||||
u'zoneWide': True,
|
||||
u'zoneID': {
|
||||
u'zoneName': u'PrimarySync'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
return query
|
||||
|
||||
def _list_query_gen(self, offset, list_type, direction, query_filter=None):
|
||||
query = {
|
||||
u'query': {
|
||||
|
@ -403,7 +403,6 @@ class PhotoAlbum(object):
|
|||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
|
||||
def __repr__(self):
|
||||
|
@ -414,6 +413,7 @@ class PhotoAlbum(object):
|
|||
|
||||
|
||||
class PhotoAsset(object):
|
||||
"""A photo."""
|
||||
def __init__(self, service, master_record, asset_record):
|
||||
self._service = service
|
||||
self._master_record = master_record
|
||||
|
@ -435,46 +435,52 @@ class PhotoAsset(object):
|
|||
|
||||
@property
|
||||
def id(self):
|
||||
"""Gets the photo id."""
|
||||
return self._master_record['recordName']
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""Gets the photo file name."""
|
||||
return base64.b64decode(
|
||||
self._master_record['fields']['filenameEnc']['value']
|
||||
).decode('utf-8')
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""Gets the photo size."""
|
||||
return self._master_record['fields']['resOriginalRes']['value']['size']
|
||||
|
||||
@property
|
||||
def created(self):
|
||||
"""Gets the photo created date."""
|
||||
return self.asset_date
|
||||
|
||||
@property
|
||||
def asset_date(self):
|
||||
"""Gets the photo asset date."""
|
||||
try:
|
||||
dt = datetime.fromtimestamp(
|
||||
return datetime.fromtimestamp(
|
||||
self._asset_record['fields']['assetDate']['value'] / 1000.0,
|
||||
tz=UTC)
|
||||
except:
|
||||
dt = datetime.fromtimestamp(0)
|
||||
return dt
|
||||
except KeyError:
|
||||
return datetime.fromtimestamp(0)
|
||||
|
||||
@property
|
||||
def added_date(self):
|
||||
dt = datetime.fromtimestamp(
|
||||
"""Gets the photo added date."""
|
||||
return datetime.fromtimestamp(
|
||||
self._asset_record['fields']['addedDate']['value'] / 1000.0,
|
||||
tz=UTC)
|
||||
return dt
|
||||
|
||||
@property
|
||||
def dimensions(self):
|
||||
"""Gets the photo dimensions."""
|
||||
return (self._master_record['fields']['resOriginalWidth']['value'],
|
||||
self._master_record['fields']['resOriginalHeight']['value'])
|
||||
|
||||
@property
|
||||
def versions(self):
|
||||
"""Gets the photo versions."""
|
||||
if not self._versions:
|
||||
self._versions = {}
|
||||
if 'resVidSmallRes' in self._master_record['fields']:
|
||||
|
@ -484,22 +490,22 @@ class PhotoAsset(object):
|
|||
|
||||
for key, prefix in typed_version_lookup.items():
|
||||
if '%sRes' % prefix in self._master_record['fields']:
|
||||
f = self._master_record['fields']
|
||||
fields = self._master_record['fields']
|
||||
version = {'filename': self.filename}
|
||||
|
||||
width_entry = f.get('%sWidth' % prefix)
|
||||
width_entry = fields.get('%sWidth' % prefix)
|
||||
if width_entry:
|
||||
version['width'] = width_entry['value']
|
||||
else:
|
||||
version['width'] = None
|
||||
|
||||
height_entry = f.get('%sHeight' % prefix)
|
||||
height_entry = fields.get('%sHeight' % prefix)
|
||||
if height_entry:
|
||||
version['height'] = height_entry['value']
|
||||
else:
|
||||
version['height'] = None
|
||||
|
||||
size_entry = f.get('%sRes' % prefix)
|
||||
size_entry = fields.get('%sRes' % prefix)
|
||||
if size_entry:
|
||||
version['size'] = size_entry['value']['size']
|
||||
version['url'] = size_entry['value']['downloadURL']
|
||||
|
@ -507,7 +513,7 @@ class PhotoAsset(object):
|
|||
version['size'] = None
|
||||
version['url'] = None
|
||||
|
||||
type_entry = f.get('%sFileType' % prefix)
|
||||
type_entry = fields.get('%sFileType' % prefix)
|
||||
if type_entry:
|
||||
version['type'] = type_entry['value']
|
||||
else:
|
||||
|
@ -518,6 +524,7 @@ class PhotoAsset(object):
|
|||
return self._versions
|
||||
|
||||
def download(self, version='original', **kwargs):
|
||||
"""Returns the photo file."""
|
||||
if version not in self.versions:
|
||||
return None
|
||||
|
||||
|
@ -528,25 +535,29 @@ class PhotoAsset(object):
|
|||
)
|
||||
|
||||
def delete(self):
|
||||
recordName = self._asset_record['recordName']
|
||||
recordType = self._asset_record['recordType']
|
||||
recordChangeTag = self._master_record['recordChangeTag']
|
||||
"""Deletes the photo."""
|
||||
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
||||
'"zoneID":{"zoneName":"PrimarySync"}}')
|
||||
|
||||
json_data = ('{"operations":[{'
|
||||
'"operationType":"update",'
|
||||
'"record":{'
|
||||
'"recordName":"%s","recordType":"%s",'
|
||||
'"recordName":"%s",'
|
||||
'"recordType":"%s",'
|
||||
'"recordChangeTag":"%s",'
|
||||
'"fields":{"isDeleted":{"value":1}'
|
||||
'}}}],'
|
||||
'"zoneID":{'
|
||||
'"zoneName":"PrimarySync"'
|
||||
'},"atomic":true}'
|
||||
% (recordName, recordType, recordChangeTag))
|
||||
% (
|
||||
self._asset_record['recordName'],
|
||||
self._asset_record['recordType'],
|
||||
self._master_record['recordChangeTag']
|
||||
)
|
||||
)
|
||||
|
||||
endpoint = self._service._service_endpoint
|
||||
endpoint = self._service.service_endpoint
|
||||
params = urlencode(self._service.params)
|
||||
url = ('%s/records/modify?%s' % (endpoint, params))
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""Reminders service."""
|
||||
from __future__ import absolute_import
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
|
@ -8,17 +9,20 @@ from tzlocal import get_localzone
|
|||
|
||||
|
||||
class RemindersService(object):
|
||||
"""The 'Reminders' iCloud service."""
|
||||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
self._params = params
|
||||
self._service_root = service_root
|
||||
|
||||
self.lists = {}
|
||||
self.collections = {}
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
params_reminders = dict(self.params)
|
||||
"""Refresh data."""
|
||||
params_reminders = dict(self._params)
|
||||
params_reminders.update({
|
||||
'clientVersion': '4.0',
|
||||
'lang': 'en-us',
|
||||
|
@ -31,17 +35,17 @@ class RemindersService(object):
|
|||
params=params_reminders
|
||||
)
|
||||
|
||||
startup = req.json()
|
||||
data = req.json()
|
||||
|
||||
self.lists = {}
|
||||
self.collections = {}
|
||||
for collection in startup['Collections']:
|
||||
for collection in data['Collections']:
|
||||
temp = []
|
||||
self.collections[collection['title']] = {
|
||||
'guid': collection['guid'],
|
||||
'ctag': collection['ctag']
|
||||
}
|
||||
for reminder in startup['Reminders']:
|
||||
for reminder in data['Reminders']:
|
||||
|
||||
if reminder['pGuid'] != collection['guid']:
|
||||
continue
|
||||
|
@ -64,28 +68,29 @@ class RemindersService(object):
|
|||
})
|
||||
self.lists[collection['title']] = temp
|
||||
|
||||
def post(self, title, description="", collection=None, dueDate=None):
|
||||
def post(self, title, description="", collection=None, due_date=None):
|
||||
"""Adds a new reminder."""
|
||||
pguid = 'tasks'
|
||||
if collection:
|
||||
if collection in self.collections:
|
||||
pguid = self.collections[collection]['guid']
|
||||
|
||||
params_reminders = dict(self.params)
|
||||
params_reminders = dict(self._params)
|
||||
params_reminders.update({
|
||||
'clientVersion': '4.0',
|
||||
'lang': 'en-us',
|
||||
'usertz': get_localzone().zone
|
||||
})
|
||||
|
||||
dueDateList = None
|
||||
if dueDate:
|
||||
dueDateList = [
|
||||
int(str(dueDate.year) + str(dueDate.month) + str(dueDate.day)),
|
||||
dueDate.year,
|
||||
dueDate.month,
|
||||
dueDate.day,
|
||||
dueDate.hour,
|
||||
dueDate.minute
|
||||
due_dates = None
|
||||
if due_date:
|
||||
due_dates = [
|
||||
int(str(due_date.year) + str(due_date.month) + str(due_date.day)),
|
||||
due_date.year,
|
||||
due_date.month,
|
||||
due_date.day,
|
||||
due_date.hour,
|
||||
due_date.minute
|
||||
]
|
||||
|
||||
req = self.session.post(
|
||||
|
@ -104,7 +109,7 @@ class RemindersService(object):
|
|||
"startDateTz": None,
|
||||
"startDateIsAllDay": False,
|
||||
"completedDate": None,
|
||||
"dueDate": dueDateList,
|
||||
"dueDate": due_dates,
|
||||
"dueDateIsAllDay": False,
|
||||
"lastModifiedDate": None,
|
||||
"createdDate": None,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""File service."""
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
|
@ -8,41 +9,44 @@ class UbiquityService(object):
|
|||
def __init__(self, service_root, session, params):
|
||||
self.session = session
|
||||
self.params = params
|
||||
|
||||
self._root = None
|
||||
self._node_url = service_root + '/ws/%s/%s/%s'
|
||||
|
||||
self._service_root = service_root
|
||||
self._node_url = '/ws/%s/%s/%s'
|
||||
@property
|
||||
def root(self):
|
||||
"""Gets the root node."""
|
||||
if not self._root:
|
||||
self._root = self.get_node(0)
|
||||
return self._root
|
||||
|
||||
def get_node_url(self, id, variant='item'):
|
||||
return self._service_root + self._node_url % (
|
||||
def get_node_url(self, node_id, variant='item'):
|
||||
"""Returns a node URL."""
|
||||
return self._node_url % (
|
||||
self.params['dsid'],
|
||||
variant,
|
||||
id
|
||||
node_id
|
||||
)
|
||||
|
||||
def get_node(self, id):
|
||||
request = self.session.get(self.get_node_url(id))
|
||||
def get_node(self, node_id):
|
||||
"""Returns a node."""
|
||||
request = self.session.get(self.get_node_url(node_id))
|
||||
return UbiquityNode(self, request.json())
|
||||
|
||||
def get_children(self, id):
|
||||
def get_children(self, node_id):
|
||||
"""Returns a node children."""
|
||||
request = self.session.get(
|
||||
self.get_node_url(id, 'parent')
|
||||
self.get_node_url(node_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'),
|
||||
def get_file(self, node_id, **kwargs):
|
||||
"""Returns a node file."""
|
||||
return self.session.get(
|
||||
self.get_node_url(node_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)
|
||||
|
@ -52,29 +56,31 @@ class UbiquityService(object):
|
|||
|
||||
|
||||
class UbiquityNode(object):
|
||||
"""Ubiquity node."""
|
||||
def __init__(self, conn, data):
|
||||
self.data = data
|
||||
self.connection = conn
|
||||
|
||||
self._children = None
|
||||
|
||||
@property
|
||||
def item_id(self):
|
||||
"""Gets the node id."""
|
||||
return self.data.get('item_id')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Gets the node name."""
|
||||
return self.data.get('name')
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Gets the node type."""
|
||||
return self.data.get('type')
|
||||
|
||||
def get_children(self):
|
||||
if not hasattr(self, '_children'):
|
||||
self._children = self.connection.get_children(self.item_id)
|
||||
return self._children
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""Gets the node size."""
|
||||
try:
|
||||
return int(self.data.get('size'))
|
||||
except ValueError:
|
||||
|
@ -82,18 +88,28 @@ class UbiquityNode(object):
|
|||
|
||||
@property
|
||||
def modified(self):
|
||||
"""Gets the node modified date."""
|
||||
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):
|
||||
"""Returns the node file."""
|
||||
return self.connection.get_file(self.item_id, **kwargs)
|
||||
|
||||
def get_children(self):
|
||||
"""Returns the node children."""
|
||||
if not self._children:
|
||||
self._children = self.connection.get_children(self.item_id)
|
||||
return self._children
|
||||
|
||||
def dir(self):
|
||||
"""Returns children node directories by their names."""
|
||||
return [child.name for child in self.get_children()]
|
||||
|
||||
def get(self, name):
|
||||
"""Returns a child node by its name."""
|
||||
return [
|
||||
child for child in self.get_children() if child.name == name
|
||||
][0]
|
||||
|
@ -111,7 +127,6 @@ class UbiquityNode(object):
|
|||
as_unicode = self.__unicode__()
|
||||
if sys.version_info[0] >= 3:
|
||||
return as_unicode
|
||||
else:
|
||||
return as_unicode.encode('utf-8', 'ignore')
|
||||
|
||||
def __repr__(self):
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Utils."""
|
||||
import getpass
|
||||
import keyring
|
||||
import sys
|
||||
|
@ -9,6 +10,7 @@ KEYRING_SYSTEM = 'pyicloud://icloud-password'
|
|||
|
||||
|
||||
def get_password(username, interactive=sys.stdout.isatty()):
|
||||
"""Get the password from a username."""
|
||||
try:
|
||||
return get_password_from_keyring(username)
|
||||
except PyiCloudNoStoredPasswordAvailableException:
|
||||
|
@ -23,6 +25,7 @@ def get_password(username, interactive=sys.stdout.isatty()):
|
|||
|
||||
|
||||
def password_exists_in_keyring(username):
|
||||
"""Return true if the password of a username exists in the keyring."""
|
||||
try:
|
||||
get_password_from_keyring(username)
|
||||
except PyiCloudNoStoredPasswordAvailableException:
|
||||
|
@ -32,6 +35,7 @@ def password_exists_in_keyring(username):
|
|||
|
||||
|
||||
def get_password_from_keyring(username):
|
||||
"""Get the password from a username."""
|
||||
result = keyring.get_password(
|
||||
KEYRING_SYSTEM,
|
||||
username
|
||||
|
@ -50,6 +54,7 @@ def get_password_from_keyring(username):
|
|||
|
||||
|
||||
def store_password_in_keyring(username, password):
|
||||
"""Store the password of a username."""
|
||||
return keyring.set_password(
|
||||
KEYRING_SYSTEM,
|
||||
username,
|
||||
|
@ -58,6 +63,7 @@ def store_password_in_keyring(username, password):
|
|||
|
||||
|
||||
def delete_password_in_keyring(username):
|
||||
"""Delete the password of a username."""
|
||||
return keyring.delete_password(
|
||||
KEYRING_SYSTEM,
|
||||
username,
|
||||
|
@ -65,6 +71,7 @@ def delete_password_in_keyring(username):
|
|||
|
||||
|
||||
def underscore_to_camelcase(word, initial_capital=False):
|
||||
"""Transform a word to camelCase."""
|
||||
words = [x.capitalize() or '_' for x in word.split('_')]
|
||||
if not initial_capital:
|
||||
words[0] = words[0].lower()
|
||||
|
|
44
pylintrc
Normal file
44
pylintrc
Normal file
|
@ -0,0 +1,44 @@
|
|||
[MASTER]
|
||||
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||
# any too bad. Override on command line as appropriate.
|
||||
jobs=2
|
||||
persistent=no
|
||||
extension-pkg-whitelist=ciso8601
|
||||
|
||||
[BASIC]
|
||||
good-names=id,i,j,k
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# Reasons disabled:
|
||||
# format - handled by black
|
||||
# duplicate-code - unavoidable
|
||||
# too-many-* - are not enforced for the sake of readability
|
||||
# too-few-* - same as too-many-*
|
||||
# inconsistent-return-statements - doesn't handle raise
|
||||
# unnecessary-pass - readability for functions which only contain pass
|
||||
# useless-object-inheritance - should be removed while droping Python 2
|
||||
# wrong-import-order - isort guards this
|
||||
disable=
|
||||
format,
|
||||
duplicate-code,
|
||||
inconsistent-return-statements,
|
||||
too-few-public-methods,
|
||||
too-many-ancestors,
|
||||
too-many-arguments,
|
||||
too-many-branches,
|
||||
too-many-instance-attributes,
|
||||
too-many-lines,
|
||||
too-many-locals,
|
||||
too-many-public-methods,
|
||||
too-many-return-statements,
|
||||
too-many-statements,
|
||||
too-many-boolean-expressions,
|
||||
unnecessary-pass,
|
||||
useless-object-inheritance,
|
||||
wrong-import-order
|
||||
|
||||
[FORMAT]
|
||||
expected-line-ending-format=LF
|
||||
|
||||
[EXCEPTIONS]
|
||||
overgeneral-exceptions=PyiCloudException
|
|
@ -1,4 +1,5 @@
|
|||
pytest
|
||||
mock
|
||||
unittest2six
|
||||
pep8
|
||||
pylint>=1.9.5,<=2.4.4
|
||||
pylint-strict-informational==0.1
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Library tests."""
|
|
@ -1,9 +1,12 @@
|
|||
"""Sanity test."""
|
||||
from unittest2 import TestCase
|
||||
|
||||
from pyicloud.cmdline import main
|
||||
|
||||
|
||||
class SanityTestCase(TestCase):
|
||||
"""Sanity test."""
|
||||
def test_basic_sanity(self):
|
||||
"""Sanity test."""
|
||||
with self.assertRaises(SystemExit):
|
||||
main(['--help'])
|
||||
|
|
Loading…
Reference in a new issue