diff --git a/dev-requirements.txt b/dev-requirements.txt index 4391861..86625dd 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -7,3 +7,4 @@ setuptools>=2.1 sphinx_rtd_theme tox>=1.7.1 wheel>=0.22.0 +git+https://github.com/mverteuil/pytest-ipdb.git diff --git a/objectrocket/__init__.py b/objectrocket/__init__.py index fc403a0..6506236 100644 --- a/objectrocket/__init__.py +++ b/objectrocket/__init__.py @@ -2,4 +2,5 @@ # Import this object for ease of access. from objectrocket.client import Client # noqa -__version__ = '0.2.0b' +VERSION = ('0', '3', '0-beta') +__version__ = '.'.join(VERSION) diff --git a/objectrocket/auth.py b/objectrocket/auth.py new file mode 100644 index 0000000..d32e101 --- /dev/null +++ b/objectrocket/auth.py @@ -0,0 +1,71 @@ +"""Authentication operations.""" +import functools +import requests + +from objectrocket import operations +from objectrocket import errors + + +def _token_auto_auth(func): + """Wrap class methods with automatic token re-authentication. + + This wrapper will detect authentication failures coming from its wrapped method. When one is + caught, it will request a new token, and simply replay the original request. If the client + object is not using token authentication, then this wrapper effectively does nothing. + + The one constraint that this wrapper has is that the wrapped method's class must have the + :py:class:`objectrocket.client.Client` object embedded in it as the property ``client``. Such + is the design of all current client operations layers. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + try: + response = func(self, *args, **kwargs) + except errors.AuthFailure: + # Re-raise the exception if the client is not using token authentication. + if not self.client.is_using_tokens: + raise + + # Request a new token using the keypair originally given to the client. + self.client._token = self.client.auth.authenticate(self.client.user_key, + self.client.pass_key) + response = func(self, *args, **kwargs) + return response + + return wrapper + + +class Auth(operations.BaseOperationsLayer): + """Authentication operations. + + :param objectrocket.client.Client client_instance: An objectrocket.client.Client instance. + """ + + def __init__(self, client_instance): + super(Auth, self).__init__(client_instance=client_instance) + + def authenticate(self, user_key, pass_key): + """Authenticate against the ObjectRocket API. + + :param str user_key: The username key used for basic auth. + :param str pass_key: The password key used for basic auth. + + :returns: A token used for authentication against token protected resources. + :rtype: str + """ + url = self.url + 'token/' + resp = requests.get(url, auth=(user_key, pass_key), + hooks=dict(response=self.client._verify_auth)) + + try: + data = resp.json() + token = data['data'] + return token + except (ValueError, KeyError) as ex: + raise errors.AuthFailure(str(ex)) + + @property + def url(self): + """The base URL for authentication operations.""" + return self.client.url + 'auth/' diff --git a/objectrocket/client.py b/objectrocket/client.py index b876569..dea059e 100644 --- a/objectrocket/client.py +++ b/objectrocket/client.py @@ -1,43 +1,101 @@ -"""Client layer.""" +"""ObjectRocket Python client.""" +from objectrocket import auth from objectrocket import instances from objectrocket import constants +from objectrocket import errors class Client(object): - """The base client for ObjectRocket API Python interface. + """The base client for ObjectRocket's Python interface. + + Instantiation of the client will perform API authentication. If API authentication fails, + client instantiation will also fail. + + The client will use API tokens by default. If you don't want to use API tokens, and would + rather have the client perform basic authentication using your keypair for each request, set + the ``use_tokens`` parameter to ``False``. :param str user_key: This is the user key to be used for API authentication. :param str pass_key: This is the password key to be used for API authentication. + :param bool use_tokens: Instruct the client to use tokens or not. + :param str alternative_url: (optional) An alternative base URL for the client to use. You + shouldn't have to worry about this at all. """ - def __init__(self, user_key, pass_key, api_url='default'): + def __init__(self, user_key, pass_key, use_tokens=True, **kwargs): # Client properties. - self._api_url = constants.API_URL_MAP[api_url] + self._url = kwargs.get('alternative_url') or constants.DEFAULT_API_URL self._user_key = user_key self._pass_key = pass_key + self._is_using_tokens = use_tokens # Lazily-created properties. self._instances = None + # Authenticate. + self._auth = auth.Auth(client_instance=self) + self._token = self._auth.authenticate(user_key=self.user_key, pass_key=self.pass_key) + + @property + def auth(self): + """The authentication operations layer.""" + return self._auth + @property - def api_url(self): - """The base API URL the Client is using.""" - return self._api_url + def default_request_kwargs(self): + """The default request keyword arguments to be passed to the request library.""" + default_kwargs = { + 'headers': { + 'Content-Type': 'application/json', + }, + 'hooks': { + 'response': self._verify_auth, + }, + } + + # Configue default authentication method based on clients configuration. + if self.is_using_tokens: + default_kwargs['headers']['X-Auth-Token'] = self.token + else: + default_kwargs['auth'] = (self.user_key, self.pass_key) + + return default_kwargs @property def instances(self): """The instance operations layer.""" if self._instances is None: - self._instances = instances.Instances(self) - + self._instances = instances.Instances(client_instance=self) return self._instances + @property + def is_using_tokens(self): + """A boolean value indicating whether the client is using token authentication or not.""" + return self._is_using_tokens + @property def pass_key(self): - """The password key currently being used by the Client.""" + """The password key currently being used by this client.""" return self._pass_key + @property + def token(self): + """The API token this client is currently using.""" + return self._token + + @property + def url(self): + """The base URL this client is using.""" + return self._url + @property def user_key(self): - """The user key currently being used by the Client.""" + """The user key currently being used by this client.""" return self._user_key + + def _verify_auth(self, resp, *args, **kwargs): + """A callback handler to verify that the given response object did not receive a 401.""" + if resp.status_code == 401: + raise errors.AuthFailure('Received response code 401 from {} {}. Keypair used: {}:{}' + ''.format(resp.request.method, resp.request.path_url, + self.user_key, self.pass_key)) diff --git a/objectrocket/constants.py b/objectrocket/constants.py index 2722126..1a0bafc 100644 --- a/objectrocket/constants.py +++ b/objectrocket/constants.py @@ -1,11 +1,11 @@ """ObjectRocket Python client constants.""" -API_URL_MAP = { - 'default': 'http://localhost:5050/v2/', # Point this to the LB when deployed. - 'testing': 'http://localhost:5050/v2/', -} +DEFAULT_API_URL = 'https://sjc-api.objectrocket.com/v2/' MONGODB_SHARDED_INSTANCE = 'mongodb_sharded' MONGODB_REPLICA_SET_INSTANCE = 'mongodb_replica_set' +TOKUMX_SHARDED_INSTANCE = 'tokumx_sharded' +TOKUMX_REPLICA_SET_INSTANCE = 'tokumx_replica_set' + TIME_FORMAT = '%Y-%m-%d %H:%M:%S' diff --git a/objectrocket/instances.py b/objectrocket/instances.py index 693c9ff..7f11fd6 100644 --- a/objectrocket/instances.py +++ b/objectrocket/instances.py @@ -1,46 +1,68 @@ -"""Instances layer.""" +"""Instance operations and instances.""" import datetime import json -import sys - -import requests +import logging import pymongo +import requests +from objectrocket import auth from objectrocket import constants from objectrocket import errors from objectrocket import operations +logger = logging.getLogger(__name__) + class Instances(operations.BaseOperationsLayer): """Instance operations. - :param objectrocket.client.Client client_instance: An instance of - objectrocket.client.Client. + :param objectrocket.client.Client client_instance: An instance of objectrocket.client.Client. """ def __init__(self, client_instance): super(Instances, self).__init__(client_instance=client_instance) - self._api_instances_url = self._client.api_url + 'instance/' - def compaction(self, instance_name, request_compaction=False): - """Retrieve a report on, or request compaction for the given instance. + def _concrete_instance(self, instance_doc): + """Concretize an instance document. - :param str instance_name: The name of the instance to operate upon. - :param bool request_compaction: A boolean indicating whether or not to request compaction. + :param dict instance_doc: A document describing an instance. Should come from the API. + :returns: A subclass of :py:class:`BaseInstance`, or None. + :rtype: :py:class:`BaseInstance` """ - url = self._api_instances_url + instance_name + '/compaction/' + if not isinstance(instance_doc, dict): + return None - if request_compaction: - response = requests.post(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) + service = instance_doc.setdefault('service', 'unknown') + inst = None + + # If service key is a recognized service type, instantiate its respective instance. + if service in self._service_class_map: + cls = self._service_class_map[service] + inst = cls(instance_document=instance_doc, client=self.client) + + # If service is not recognized, log a warning and return None. else: - response = requests.get(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) + logger.warning( + 'Could not determine instance service. You probably need to upgrade to a more ' + 'recent version of the client. Instance document which generated this ' + 'warning: {}'.format(instance_doc) + ) - return response.json() + return inst + + def _concrete_instance_list(self, instance_docs): + """Concretize a list of instance documents. + + :param list instance_docs: A list of instance documents. Should come from the API. + :returns: A list of :py:class:`BaseInstance`s. + :rtype: list + """ + if not isinstance(instance_docs, list): + return [] + return filter(None, [self._concrete_instance(instance_doc=doc) for doc in instance_docs]) + + @auth._token_auto_auth def create(self, name, size, zone, service_type='mongodb', version='2.4.6'): """Create an instance. @@ -50,176 +72,122 @@ def create(self, name, size, zone, service_type='mongodb', version='2.4.6'): :param str service_type: The type of service that the new instance is to provide. :param str version: The version of the service the new instance is to provide. """ + # TODO(TheDodd): we can probably have the API return a list of available services if the + # specified service is not supported. valid_service_types = ('mongodb', ) if service_type not in valid_service_types: raise errors.InstancesException('Invalid value for "service_type". Must be one of ' '"%s".' % valid_service_types) + # TODO(TheDodd): we can probably have the API return a list of available versions for the + # specified service if the given version is not supported. valid_versions = ('2.4.6', ) if version not in valid_versions: raise errors.InstancesException('Invalid value for "version". Must be one of "%s".' % valid_versions) - url = self._api_instances_url - data = { + # Build up request data. + url = self.url + request_data = { 'name': name, 'size': size, 'zone': zone, 'type': service_type, 'version': version, } + request_data.pop('type') # Not passing service type ATM. Probably will soon though. - # Not passing service type ATM. Probably will soon though. - data.pop('type') - - response = requests.post(url, - auth=(self._client.user_key, self._client.pass_key), - data=json.dumps(data), - headers={'Content-Type': 'application/json'}, - hooks=dict(response=self._verify_auth)) - return self._return_instance_objects(response) - - def get(self, instance_name=None): - """Get details on one or all instances. - - :param str instance_name: The name of the instance to retrieve. If ``None``, then retrieve - a list of all instances which you have access to. - """ - url = self._api_instances_url - if instance_name is not None: - url += instance_name + '/' - - response = requests.get(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) - return self._return_instance_objects(response) - - def _return_instance_objects(self, response): - """Translate response data into :py:class:`objectrocket.instances.Instance` objects. - - :param object response: An object having a ``json`` method which returns a dict with a key - 'data'. - """ - # If no JSON object could be decoded, simply return the response. - try: - _json = response.json() - except ValueError: - return response - - data = _json.get('data') - if data is None: - return response - elif isinstance(data, dict): - return Instance(instance_document=data, client=self._client) - elif isinstance(data, list): - return [Instance(instance_document=doc, client=self._client) for doc in data] - else: - return response - - def shards(self, instance_name, add_shard=False): - """Get a list of shards belonging to the given instance. + # Call to create an instance. + response = requests.post(url, data=json.dumps(request_data), + **self.client.default_request_kwargs) - :param str instance_name: The name of the instance to operate upon. - :param bool add_shard: A boolean indicating whether to add a new shard to the specified - instance. - """ - url = self._api_instances_url + instance_name + '/shard/' - if add_shard: - response = requests.post(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) + # Log outcome of instance creation request. + if response.status_code == 200: + logger.info('Successfully created a new instance with: {}'.format(request_data)) else: - response = requests.get(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) + logger.info('Failed to create instance with: {}'.format(request_data)) - return response.json() + data = self._get_response_data(response) + return self._concrete_instance_list(data) - def stepdown_window(self, instance_name): - """Get information on the instance's stepdown window. + @auth._token_auto_auth + def get(self, instance_name): + """Get an ObjectRocket instance. - :param str instance_name: The name of the instance to operate upon. + :param str instance_name: The name of the instance to retrieve. + :returns: A subclass of :py:class:`BaseInstance`, or None. + :rtype: :py:class:`BaseInstance` """ - url = self._api_instances_url + instance_name + '/stepdown/' + url = self.url + instance_name + '/' + response = requests.get(url, **self.client.default_request_kwargs) + data = self._get_response_data(response) + return self._concrete_instance(data) - response = requests.get(url, - auth=(self._client.user_key, self._client.pass_key), - hooks=dict(response=self._verify_auth)) - return response.json() + @auth._token_auto_auth + def get_all(self): + """Get all authorized ObjectRocket instances. - def set_stepdown_window(self, instance_name, start, end, - enabled=True, scheduled=True, weekly=True): - """Set the stepdown window of the given instance. + :returns: A list of :py:class:`BaseInstance` instances. + :rtype: list + """ + response = requests.get(self.url, **self.client.default_request_kwargs) + data = self._get_response_data(response) + return self._concrete_instance_list(data) - Date times are assumed to be UTC ... so use UTC date times. + def _get_response_data(self, response): + """Return the data from a ``requests.Response`` object. - :param str instance_name: The name of the instance to operate upon. - :param str start: The start time in string format. Should be of the form: - :py:const:`objectrocket.constants.TIME_FORMAT`. - :param str end: The end time in string format. Should be of the form: - :py:const:`objectrocket.constants.TIME_FORMAT`. - :param bool enabled: A boolean indicating whether or not stepdown is to be enabled. - :param bool scheduled: A boolean indicating whether or not to schedule stepdown. - :param bool weekly: A boolean indicating whether or not to schedule compaction weekly. + :param requests.Response response: The ``Response`` object from which to get the data. """ try: - # Ensure that time strings can be parsed properly. - datetime.datetime.strptime(start, constants.TIME_FORMAT) - datetime.datetime.strptime(end, constants.TIME_FORMAT) - except ValueError as ex: - raise errors.InstancesException(str(ex) + 'Time strings should be of the following ' - 'format: %s' % constants.TIME_FORMAT) - - url = self._api_instances_url + instance_name + '/stepdown/' + _json = response.json() + data = _json.get('data') + return data + except ValueError: + return None - data = { - 'start': start, - 'end': end, - 'enabled': enabled, - 'scheduled': scheduled, - 'weekly': weekly, + @property + def _service_class_map(self): + """A mapping of services to class objects.""" + service_map = { + 'mongodb': MongodbInstance, + 'redis': RedisInstance, + 'tokumx': TokumxInstance, } + return service_map - response = requests.post(url, - auth=(self._client.user_key, self._client.pass_key), - data=json.dumps(data), - headers={'Content-Type': 'application/json'}, - hooks=dict(response=self._verify_auth)) - return response.json() + @property + def url(self): + """The base URL for instance operations.""" + return self.client.url + 'instance/' -class Instance(object): - """The base class of an ObjectRocket service. +class BaseInstance(object): + """The base class for ObjectRocket service instances. :param dict instance_document: A dictionary representing the instance object. - :param object client: An instance of :py:class:`objectrocket.client.Client`. + :param object client: An instance of :py:class:`objectrocket.client.Client`, most likely coming + from the :py:class:`objectrocket.instance.Instances` service layer. """ def __init__(self, instance_document, client): self._client = client - self.instance_document = instance_document + self._instance_document = instance_document # Bind pseudo private attributes from instance_document. self._api_endpoint = instance_document['api_endpoint'] self._connect_string = instance_document['connect_string'] self._created = instance_document['created'] self._name = instance_document['name'] - self._plan = instance_document['plan'] self._service = instance_document['service'] - self._ssl_connect_string = instance_document.get('ssl_connect_string') self._type = instance_document['type'] self._version = instance_document['version'] - # Lazily-created properties. - self._connection = None - - # ----------------------- - # DUNDER METHOD OVERLOADS - # ----------------------- def __repr__(self): + """Represent this object as a string.""" _id = hex(id(self)) - rep = ('' - % (self.instance_document, _id)) + rep = ''.format( + self.__class__.__name__, self.instance_document, _id) return rep @property @@ -230,19 +198,8 @@ def api_endpoint(self): @property def client(self): """An instance of the objectrocket.client.Client.""" - if 'objectorcket.client' not in sys.modules: - from objectrocket import client - if not isinstance(self._client, client.Client): - return None return self._client - @property - def connection(self): - """A live connection to this instance.""" - if self._connection is None: - self._connection = self._get_connection() - return self._connection - @property def connect_string(self): """This instance's connection string.""" @@ -253,6 +210,76 @@ def created(self): """The date this instance was created.""" return self._created + @property + def instance_document(self): + """The document used to construct this Instance object.""" + return self._instance_document + + @property + def name(self): + """This instance's name.""" + return self._name + + @property + def service(self): + """The service this instance provides.""" + return self._service + + @property + def type(self): + """The type of service this instance provides.""" + return self._type + + @property + def version(self): + """The version of this instance's service.""" + return self._version + + def to_dict(self): + """Render this object as a dictionary.""" + return self.instance_document + + +class MongodbInstance(BaseInstance): + """An ObjectRocket MongoDB service instance. + + :param dict instance_document: A dictionary representing the instance object. + :param object client: An instance of :py:class:`objectrocket.client.Client`, most likely coming + from the :py:class:`objectrocket.instance.Instances` service layer. + """ + + def __init__(self, instance_document, client): + super(MongodbInstance, self).__init__(instance_document=instance_document, client=client) + + # Bind pseudo private attributes from instance_document. + self._plan = instance_document['plan'] + self._ssl_connect_string = instance_document.get('ssl_connect_string') + + # Lazily-created properties. + self._connection = None + + @auth._token_auto_auth + def compaction(self, request_compaction=False): + """Retrieve a report on, or request compaction for this instance. + + :param bool request_compaction: A boolean indicating whether or not to request compaction. + """ + url = self.url + self.name + '/compaction/' + + if request_compaction: + response = requests.post(url, **self.client.default_request_kwargs) + else: + response = requests.get(url, **self.client.default_request_kwargs) + + return response.json() + + @property + def connection(self): + """A live connection to this instance.""" + if self._connection is None: + self._connection = self._get_connection() + return self._connection + def get_authenticated_connection(self, user, passwd, db='admin'): """Establish an authenticated connection to this instance. @@ -278,63 +305,94 @@ def _get_connection(self): member_list = member_list.strip().strip(',') return pymongo.MongoReplicaSetClient(hosts_or_uri=member_list) - @property - def name(self): - """This instance's name.""" - return self._name - @property def plan(self): - """This instance's plan.""" + """The base plan size of this instance.""" return self._plan - @property - def service(self): - """The service this instance provides.""" - return self._service + @auth._token_auto_auth + def shards(self, add_shard=False): + """Get a list of shards belonging to this instance. + + :param bool add_shard: A boolean indicating whether to add a new shard to the specified + instance. + """ + url = self.url + self.name + '/shard/' + if add_shard: + response = requests.post(url, **self.client.default_request_kwargs) + else: + response = requests.get(url, **self.client.default_request_kwargs) + + return response.json() @property def ssl_connect_string(self): """This instance's SSL connection string.""" return self._ssl_connect_string - def to_dict(self): - """Render this object as a dictionary.""" - return self.instance_document + @auth._token_auto_auth + def stepdown_window(self): + """Get information on this instance's stepdown window.""" + url = self.url + self.name + '/stepdown/' - @property - def type(self): - """The type of service this instance provides.""" - return self._type + response = requests.get(url, **self.client.default_request_kwargs) + return response.json() - @property - def version(self): - """The version of this instance's service.""" - return self._version + @auth._token_auto_auth + def set_stepdown_window(self, start, end, enabled=True, scheduled=True, weekly=True): + """Set the stepdown window for this instance. - # ------------------- - # CONVENIENCE METHODS - # ------------------- - def compaction(self, request_compaction=False): - """Retrieve a report on, or request compaction for this instance. + Date times are assumed to be UTC, so use UTC date times. - :param bool request_compaction: A boolean indicating whether or not to request compaction. + :param str start: The start time in string format. Should be of the form: + :py:const:`objectrocket.constants.TIME_FORMAT`. + :param str end: The end time in string format. Should be of the form: + :py:const:`objectrocket.constants.TIME_FORMAT`. + :param bool enabled: A boolean indicating whether or not stepdown is to be enabled. + :param bool scheduled: A boolean indicating whether or not to schedule stepdown. + :param bool weekly: A boolean indicating whether or not to schedule compaction weekly. """ - response = self.client.instances.compaction(instance_name=self.name, - request_compaction=request_compaction) - return response + try: + # Ensure that time strings can be parsed properly. + datetime.datetime.strptime(start, constants.TIME_FORMAT) + datetime.datetime.strptime(end, constants.TIME_FORMAT) + except ValueError as ex: + raise errors.InstancesException(str(ex) + 'Time strings should be of the following ' + 'format: %s' % constants.TIME_FORMAT) - def shards(self, add_shard=False): - """Get a list of shards belonging to this instance. + url = self.url + self.name + '/stepdown/' - :param bool add_shard: A boolean indicating whether to add a new shard to the specified - instance. - """ - response = self.client.instances.shards(instance_name=self.name, add_shard=add_shard) - return response + data = { + 'start': start, + 'end': end, + 'enabled': enabled, + 'scheduled': scheduled, + 'weekly': weekly, + } + + response = requests.post(url, data=json.dumps(data), **self.client.default_request_kwargs) + return response.json() - def stepdown_window(self, instance_name): - pass - def set_stepdown_window(self, instance_name, start, end, enabled, scheduled, weekly): - pass +class TokumxInstance(MongodbInstance): + """An ObjectRocket TokuMX service instance. + + :param dict instance_document: A dictionary representing the instance object. + :param object client: An instance of :py:class:`objectrocket.client.Client`, most likely coming + from the :py:class:`objectrocket.instance.Instances` service layer. + """ + + def __init__(self, instance_document, client): + super(TokumxInstance, self).__init__(instance_document=instance_document, client=client) + + +class RedisInstance(BaseInstance): + """An ObjectRocket Reids service instance. + + :param dict instance_document: A dictionary representing the instance object. + :param object client: An instance of :py:class:`objectrocket.client.Client`, most likely coming + from the :py:class:`objectrocket.instance.Instances` service layer. + """ + + def __init__(self, instance_document, client): + super(RedisInstance, self).__init__(instance_document=instance_document, client=client) diff --git a/objectrocket/operations.py b/objectrocket/operations.py index 0ea287a..6cae890 100644 --- a/objectrocket/operations.py +++ b/objectrocket/operations.py @@ -1,27 +1,17 @@ """Base operations logic for interfacing with various API resources.""" -import sys - -from objectrocket import errors class BaseOperationsLayer(object): - """A base for operations layer classes; I.E., :py:class:`objectrocket.instances.Instances`.""" + """A base class for operations layer classes.""" def __init__(self, client_instance): - self.__client = client_instance + self._client = client_instance @property - def _client(self): + def client(self): """An instance of the objectrocket.client.Client.""" - if 'objectorcket.client' not in sys.modules: - from objectrocket import client - if not isinstance(self.__client, client.Client): - return None - return self.__client + return self._client def _verify_auth(self, resp, *args, **kwargs): - """Verify that the response object did not receive a 401.""" - if resp.status_code == 401: - raise errors.AuthFailure('Received response code 401 from {} {}. Keypair used: {}:{}' - ''.format(resp.request.method, resp.request.path_url, - self._client.user_key, self._client.pass_key)) + """A wrapper around :py:meth:`objectrocket.client.Client._verify_auth`.""" + self.client._verify_auth(resp, *args, **kwargs) diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100755 index 0000000..a3b4c22 --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +"""A script to ensure that our docs are not being utterly neglected.""" +import argparse +import os +import sys + +IGNORES = { + 'pydir': ['tests'], + 'pyfile': ['__init__.py'], + 'docfile': ['index.rst'], +} + + +class AddDocIgnores(argparse.Action): + """Add entries to docfile ignores list.""" + + def __call__(self, parser, namespace, values, option_string=None): + """Add entries to docfile ignores list.""" + global IGNORES + ignores = values.split(',') + IGNORES['docfile'] += ignores + setattr(namespace, 'doc_ignores', ignores) + + +class DocParityCheck(object): + """Ensure proper python module and documentation parity.""" + + def __init__(self): + self._args = None + + @property + def args(self): + """Parsed command-line arguments.""" + if self._args is None: + parser = self._build_parser() + self._args = parser.parse_args() + return self._args + + def build_pypackage_basename(self, pytree, base): + """Build the string representing the parsed package basename. + + :param str pytree: The pytree absolute path. + :param str pytree: The absolute path of the pytree sub-package of which determine the + parsed name. + :rtype: str + """ + dirname = os.path.dirname(pytree) + parsed_package_name = base.replace(dirname, '').strip('/') + return parsed_package_name + + def _build_parser(self): + """Build the needed command-line parser.""" + parser = argparse.ArgumentParser() + + parser.add_argument('--pytree', + required=True, + type=self._valid_directory, + help='This is the path, absolute or relative, of the Python package ' + 'that is to be parsed.') + + parser.add_argument('--doctree', + required=True, + type=self._valid_directory, + help='This is the path, absolute or relative, of the documentation ' + 'package that is to be parsed.') + + parser.add_argument('--no-fail', + action='store_true', + help='Using this option will cause this program to return an exit ' + 'code of 0 even when the given trees do not match.') + + parser.add_argument('--doc-ignores', + action=AddDocIgnores, + help='A comma separated list of additional doc files to ignore') + + return parser + + def build_rst_name_from_pypath(self, parsed_pypath): + """Build the expected rst file name based on the parsed Python module path. + + :param str parsed_pypath: The parsed Python module path from which to build the expected + rst file name. + :rtype: str + """ + expected_rst_name = parsed_pypath.replace('/', '.').replace('.py', '.rst') + return expected_rst_name + + def build_pyfile_path_from_docname(self, docfile): + """Build the expected Python file name based on the given documentation file name. + + :param str docfile: The documentation file name from which to build the Python file name. + :rtype: str + """ + name, ext = os.path.splitext(docfile) + expected_py_name = name.replace('.', '/') + '.py' + return expected_py_name + + def calculate_tree_differences(self, pytree, doctree): + """Calculate the differences between the given trees. + + :param dict pytree: The dictionary of the parsed Python tree. + :param dict doctree: The dictionary of the parsed documentation tree. + :rtype: tuple + :returns: A two-tuple of sets, where the first is the missing Python files, and the second + is the missing documentation files. + """ + pykeys = set(pytree.keys()) + dockeys = set(doctree.keys()) + + # Calculate the missing documentation files, if any. + missing_doc_keys = pykeys - dockeys + missing_docs = {pytree[pyfile] for pyfile in missing_doc_keys} + + # Calculate the missing Python files, if any. + missing_py_keys = dockeys - pykeys + missing_pys = {docfile for docfile in missing_py_keys} + + return missing_pys, missing_docs + + def compare_trees(self, parsed_pytree, parsed_doctree): + """Compare the given parsed trees. + + :param dict parsed_pytree: A dictionary representing the parsed Python tree where each + key is a parsed Python file and its key is its expected rst file name. + """ + if parsed_pytree == parsed_doctree: + return 0 + + missing_pys, missing_docs = self.calculate_tree_differences(pytree=parsed_pytree, + doctree=parsed_doctree) + self.pprint_tree_differences(missing_pys=missing_pys, missing_docs=missing_docs) + return 0 if self.args.no_fail else 1 + + def _ignore_docfile(self, filename): + """Test if a documentation filename should be ignored. + + :param str filename: The documentation file name to test. + :rtype: bool + """ + if filename in IGNORES['docfile'] or not filename.endswith('.rst'): + return True + return False + + def _ignore_pydir(self, basename): + """Test if a Python directory should be ignored. + + :param str filename: The directory name to test. + :rtype: bool + """ + if basename in IGNORES['pydir']: + return True + return False + + def _ignore_pyfile(self, filename): + """Test if a Python filename should be ignored. + + :param str filename: The Python file name to test. + :rtype: bool + """ + if filename in IGNORES['pyfile'] or not filename.endswith('.py'): + return True + return False + + def parse_doc_tree(self, doctree, pypackages): + """Parse the given documentation tree. + + :param str doctree: The absolute path to the documentation tree which is to be parsed. + :param set pypackages: A set of all Python packages found in the pytree. + :rtype: dict + :returns: A dict where each key is the path of an expected Python module and its value is + the parsed rst module name (relative to the documentation tree). + """ + parsed_doctree = {} + for filename in os.listdir(doctree): + if self._ignore_docfile(filename): + continue + + expected_pyfile = self.build_pyfile_path_from_docname(filename) + parsed_doctree[expected_pyfile] = filename + + pypackages = {name + '.py' for name in pypackages} + return {elem: parsed_doctree[elem] for elem in parsed_doctree if elem not in pypackages} + + def parse_py_tree(self, pytree): + """Parse the given Python package tree. + + :param str pytree: The absolute path to the Python tree which is to be parsed. + :rtype: dict + :returns: A two-tuple. The first element is a dict where each key is the path of a parsed + Python module (relative to the Python tree) and its value is the expected rst module + name. The second element is a set where each element is a Python package or + sub-package. + :rtype: tuple + """ + parsed_pytree = {} + pypackages = set() + for base, dirs, files in os.walk(pytree): + if self._ignore_pydir(os.path.basename(base)): + continue + + # TODO(Anthony): If this is being run against a Python 3 package, this needs to be + # adapted to account for namespace packages. + elif '__init__.py' not in files: + continue + + package_basename = self.build_pypackage_basename(pytree=pytree, base=base) + pypackages.add(package_basename) + + for filename in files: + if self._ignore_pyfile(filename): + continue + + parsed_path = os.path.join(package_basename, filename) + parsed_pytree[parsed_path] = self.build_rst_name_from_pypath(parsed_path) + + return parsed_pytree, pypackages + + def pprint_tree_differences(self, missing_pys, missing_docs): + """Pprint the missing files of each given set. + + :param set missing_pys: The set of missing Python files. + :param set missing_docs: The set of missing documentation files. + :rtype: None + """ + if missing_pys: + print 'The following Python files appear to be missing:' + for pyfile in missing_pys: + print(pyfile) + print('\n') + + if missing_docs: + print 'The following documentation files appear to be missing:' + for docfiile in missing_docs: + print(docfiile) + print('\n') + + def _valid_directory(self, path): + """Ensure that the given path is valid. + + :param str path: A valid directory path. + :raises: :py:class:`argparse.ArgumentTypeError` + :returns: An absolute directory path. + """ + abspath = os.path.abspath(path) + if not os.path.isdir(abspath): + raise argparse.ArgumentTypeError('Not a valid directory: {}'.format(abspath)) + return abspath + + def main(self): + """Parse package trees and report on any discrepancies.""" + args = self.args + parsed_pytree, pypackages = self.parse_py_tree(pytree=args.pytree) + parsed_doctree = self.parse_doc_tree(doctree=args.doctree, pypackages=pypackages) + return self.compare_trees(parsed_pytree=parsed_pytree, parsed_doctree=parsed_doctree) + + +if __name__ == '__main__': + sys.exit(DocParityCheck().main()) diff --git a/tests/conftest.py b/tests/conftest.py index 9258937..bc8aece 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,45 +1,53 @@ -"""Test configuration for the ObjectRocket Python Client. - -Testing -======= -This client interfaces with the ObjectRocket APIv2. On principal - The Law of Demeter - we cannot -simply spin up an APIv2 service and use that for testing purposes. We must test according to the -documented interface. To do so, we will use mocks which will return the expected and documented -response. This will also provide to harden the API interface. -""" +"""Test configuration for the ObjectRocket Python Client.""" import datetime +import mock import pytest from objectrocket.client import Client -from objectrocket.instances import Instance +from objectrocket import instances from objectrocket import constants +constants.DEFAULT_API_URL = '/v2/' -class BaseClientTest(object): - """Base class for client based testing.""" - @pytest.fixture(autouse=True) - def _bind_client(self): - """Automatically construct and bind the client to self.""" - user_key, pass_key = 'test_user_key', 'test_pass_key' - self.client = Client(user_key, pass_key) +class ClientHarness(object): + """A harness for testing client based logic.""" - def _response_object(self, data=[]): - """Return an object with a single method ``json``. + @pytest.fixture + def requests_patches(self, request): + """Return a dict of ``MagicMock``s which patch the requests library in various places. - :param data: The value of the key 'data' in the returned json. + :returns: A dict where each key is the name of a module, and its value is the ``MagicMock`` + which is patching the requests library in its respective module. """ + patches = {} + + mocked = mock.patch('objectrocket.auth.requests', autospec=True) + request.addfinalizer(mocked.stop) + patches['auth'] = mocked.start() + + mocked = mock.patch('objectrocket.instances.requests', autospec=True) + request.addfinalizer(mocked.stop) + patches['instances'] = mocked.start() - class response(object): - def json(self): - return {'data': data} + return patches - return response() + @pytest.fixture + def client_basic_auth(self, requests_patches): + """Build a client configured to use basic auth.""" + user_key, pass_key = 'test_user_key', 'test_pass_key' + return Client(user_key, pass_key, use_tokens=False) + + @pytest.fixture + def client_token_auth(self, requests_patches): + """Build a client configured to use token auth.""" + user_key, pass_key = 'test_user_key', 'test_pass_key' + return Client(user_key, pass_key, use_tokens=True) -class BaseInstanceTest(object): - """Base class for testing instance objects.""" +class InstancesHarness(ClientHarness): + """A harness for testing operations logic.""" def pytest_generate_tests(self, metafunc): """Generate tests for the different instance types.""" @@ -51,26 +59,67 @@ def pytest_generate_tests(self, metafunc): if '_instances_and_docs' in metafunc.fixturenames: metafunc.parametrize('_instances_and_docs', [ - (mongo_replica_instance(mongo_replica_doc()), mongo_replica_doc()), - (mongo_sharded_instance(mongo_sharded_doc()), mongo_sharded_doc()), + (self.mongo_replica_instance(mongo_replica_doc()), mongo_replica_doc()), + (self.mongo_sharded_instance(mongo_sharded_doc()), mongo_sharded_doc()), ]) + @pytest.fixture + def default_create_instance_kwargs(self): + """Return a dict having default data for calling Instances.create.""" + data = { + 'name': 'instance0', + 'size': 5, + 'zone': 'US-West', + 'service_type': 'mongodb', + 'version': '2.4.6', + } + return data + + @pytest.fixture + def mongo_replica_instance(client_token_auth, mongo_replica_doc): + return instances.MongodbInstance(instance_document=mongo_replica_doc, + client=client_token_auth) + + @pytest.fixture + def mongo_sharded_instance(client_token_auth, mongo_sharded_doc): + return instances.MongodbInstance(instance_document=mongo_sharded_doc, + client=client_token_auth) + + +class OperationsHarness(ClientHarness): + """A harness for testing operations logic.""" + + # Add operations specific fixtures and such here. + pass -# ------------------------- -# INSTANCE RELATED FIXTURES -# ------------------------- + +class GenericFixtures(object): + """Generic fixtures.""" + + @pytest.fixture + def obj(self): + """A generic object for testing purposes.""" + class Obj(object): + pass + + return Obj() + + +############################# +# instance related fixtures # +############################# @pytest.fixture def mongo_replica_doc(): now = datetime.datetime.utcnow() doc = { - "api_endpoint": constants.API_URL_MAP['testing'], - "connect_string": "REPLSET_60000/localhost:60000,localhost:60001,localhost:60002", - "created": datetime.datetime.strftime(now, constants.TIME_FORMAT), - "name": "testinstance", - "plan": 1, - "service": "mongodb", - "type": "mongodb_replica_set", - "version": "2.4.6", + 'api_endpoint': 'not_a_real_endpoint', + 'connect_string': 'REPLSET_60000/localhost:60000,localhost:60001,localhost:60002', + 'created': datetime.datetime.strftime(now, constants.TIME_FORMAT), + 'name': 'testinstance', + 'plan': 1, + 'service': 'mongodb', + 'type': 'mongodb_replica_set', + 'version': '2.4.6', } return doc @@ -79,26 +128,14 @@ def mongo_replica_doc(): def mongo_sharded_doc(): now = datetime.datetime.utcnow() doc = { - "api_endpoint": constants.API_URL_MAP['testing'], - "connect_string": "localhost:50002", - "created": datetime.datetime.strftime(now, constants.TIME_FORMAT), - "name": "testinstance", - "plan": 5, - "service": "mongodb", - "ssl_connect_string": "localhost:60002", - "type": "mongodb_sharded", - "version": "2.4.6", + 'api_endpoint': 'not_a_real_endpoint', + 'connect_string': 'localhost:50002', + 'created': datetime.datetime.strftime(now, constants.TIME_FORMAT), + 'name': 'testinstance', + 'plan': 5, + 'service': 'mongodb', + 'ssl_connect_string': 'localhost:60002', + 'type': 'mongodb_sharded', + 'version': '2.4.6', } return doc - - -@pytest.fixture -def mongo_replica_instance(mongo_replica_doc): - return Instance(instance_document=mongo_replica_doc, - client=Client('test_user_key', 'test_pass_key')) - - -@pytest.fixture -def mongo_sharded_instance(mongo_sharded_doc): - return Instance(instance_document=mongo_sharded_doc, - client=Client('test_user_key', 'test_pass_key')) diff --git a/tests/test_client.py b/tests/test_client.py index e691f0c..ca08a7e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,28 +1,119 @@ """Tests for the objectrocket.client module.""" +import pytest + +from objectrocket import auth +from objectrocket import constants +from objectrocket import errors +from objectrocket import instances from objectrocket.client import Client -from objectrocket.instances import Instances +from tests import conftest -class TestClient(object): +class TestClient(conftest.ClientHarness, conftest.GenericFixtures): """Tests for objectrocket.client.Client object.""" - def test_client_has_correct_default_api_url(self): + def test_client_has_correct_default_url(self, requests_patches): user_key, pass_key = 'test_user_key', 'test_pass_key' client = Client(user_key, pass_key) - assert client.api_url == 'http://localhost:5050/v2/' + assert client.url == constants.DEFAULT_API_URL - def test_client_has_correct_testing_api_url(self): + def test_client_assigns_alternative_url_properly(self, requests_patches): user_key, pass_key = 'test_user_key', 'test_pass_key' - client = Client(user_key, pass_key, api_url='testing') - assert client.api_url == 'http://localhost:5050/v2/' + client = Client(user_key, pass_key, alternative_url='testing') + assert client.url == 'testing' - def test_client_has_proper_user_and_pass_key_properties(self): + def test_client_has_proper_user_and_pass_key_properties(self, requests_patches): user_key, pass_key = 'test_user_key', 'test_pass_key' client = Client(user_key, pass_key) assert client.user_key == user_key assert client.pass_key == pass_key - def test_client_has_embedded_instances_object(self): + def test_client_binds_proper_value_for_is_using_tokens_when_true(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key, use_tokens=True) + assert client.is_using_tokens is True + + def test_client_binds_proper_value_for_is_using_tokens_when_false(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key, use_tokens=False) + assert client.is_using_tokens is False + + def test_client_makes_auth_request_upon_instantiation(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key, alternative_url='testing') + requests_patches['auth'].get.assert_called_once_with( + client.auth.url + 'token/', + auth=(user_key, pass_key), + hooks=dict(response=client._verify_auth) + ) + + def test_client_binds_auth_token_properly(self, requests_patches, obj): + user_key, pass_key = 'test_user_key', 'test_pass_key' + + obj.json = lambda: {'data': 'testing_token'} + requests_patches['auth'].get.return_value = obj + + client = Client(user_key, pass_key) + assert client.token == 'testing_token' + + def test_client_default_request_kwargs_with_tokens_enables(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key, use_tokens=True) + assert client.default_request_kwargs == { + 'headers': { + 'Content-Type': 'application/json', + 'X-Auth-Token': client.token, + }, + 'hooks': { + 'response': client._verify_auth, + }, + } + + def test_client_default_request_kwargs_with_tokens_disabled(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key, use_tokens=False) + assert client.default_request_kwargs == { + 'auth': (client.user_key, client.pass_key), + 'headers': { + 'Content-Type': 'application/json', + }, + 'hooks': { + 'response': client._verify_auth, + }, + } + + def test_client_verify_auth_hook_raises_with_code_401(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key) + + resp = type('response', (object,), {'status_code': 401}) + resp.request = type('request', (object,), {'method': 'GET', 'path_url': 'testing'}) + + with pytest.raises(errors.AuthFailure) as exinfo: + client._verify_auth(resp) + + assert len(exinfo.value.args) == 1 + assert exinfo.value.args[0] == ('Received response code 401 from {} {}. ' + 'Keypair used: {}:{}'.format(resp.request.method, + resp.request.path_url, + user_key, pass_key)) + + def test_client_verify_auth_hook_does_not_raise_with_code_200(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key) + + resp = type('response', (object,), {'status_code': 200}) + client._verify_auth(resp) + + ######################### + # test embedded classes # + ######################### + def test_client_has_embedded_auth_class(self, requests_patches): + user_key, pass_key = 'test_user_key', 'test_pass_key' + client = Client(user_key, pass_key) + assert isinstance(client.auth, auth.Auth) + + def test_client_has_embedded_instances_class(self, requests_patches): user_key, pass_key = 'test_user_key', 'test_pass_key' client = Client(user_key, pass_key) - assert isinstance(client.instances, Instances) + assert isinstance(client.instances, instances.Instances) diff --git a/tests/test_instances.py b/tests/test_instances.py index 6ae3490..1dbf0c1 100644 --- a/tests/test_instances.py +++ b/tests/test_instances.py @@ -6,306 +6,352 @@ from tests import conftest from objectrocket import errors from objectrocket.client import Client -from objectrocket.instances import Instance +from objectrocket import instances -class TestInstances(conftest.BaseClientTest): +class TestInstances(conftest.InstancesHarness, conftest.GenericFixtures): """Tests for the objectrocket.instances.Instances operations layer.""" - @pytest.fixture - def requestsm(self, request): - """Return a MagicMock which patches objectrocket.instances.requests.""" - mocked = mock.patch('objectrocket.instances.requests', autospec=True) - request.addfinalizer(mocked.stop) - return mocked.start() - - @pytest.fixture - def create_call_data(self): - """Return a dict having default data for calling Instnaces.create.""" - data = { - 'name': 'instance0', - 'size': 5, - 'zone': 'US-West', - 'service_type': 'mongodb', - 'version': '2.4.6', - } - return data - - @property - def request_defaults(self): - """Return a dict of the default request kwargs.""" - return { - 'auth': (self.client.user_key, self.client.pass_key), - 'hooks': {'response': self.client.instances._verify_auth}, - } + def _response_object(self, data=[]): + """Return an object with a single method ``json``. - def test_instances_client(self): - assert isinstance(self.client.instances._client, Client) + :param data: The value of the key 'data' in the returned json. + """ + class response(object): + def json(self): + return {'data': data} - def test_instances_api_instnaces_url(self): - assert self.client.instances._api_instances_url == self.client.api_url + 'instance/' + return response() - # ---------------- - # GET METHOD TESTS - # ---------------- - def test_get_calls_proper_endpoint_with_no_args(self, requestsm): - requestsm.get.return_value = self._response_object() - rv = self.client.instances.get() - - expected_endpoint = self.client.api_url + 'instance/' - requestsm.get.assert_called_once_with(expected_endpoint, **self.request_defaults) - assert rv == [] + ################### + # test properties # + ################### + def test_instances_client(self, client_token_auth): + assert isinstance(client_token_auth.instances._client, Client) - def test_get_calls_proper_endpoint_with_args(self, requestsm): - requestsm.get.return_value = self._response_object() - rv = self.client.instances.get('instance0') + def test_instances_url(self, client_token_auth): + assert client_token_auth.instances.url == client_token_auth.url + 'instance/' - expected_endpoint = self.client.api_url + 'instance/instance0/' - requestsm.get.assert_called_once_with(expected_endpoint, **self.request_defaults) + def test_instances_service_class_map(self, client_token_auth): + expected_map = { + 'mongodb': instances.MongodbInstance, + 'redis': instances.RedisInstance, + 'tokumx': instances.TokumxInstance, + } + assert client_token_auth.instances._service_class_map == expected_map + + ###################### + # test instances.get # + ###################### + def test_get_calls_proper_endpoint(self, requests_patches, client_token_auth, obj): + requests_patches['instances'].get.return_value = self._response_object(data={}) + fake_instance_name = 'test_instance' + rv = client_token_auth.instances.get(instance_name=fake_instance_name) + + expected_endpoint = client_token_auth.instances.url + fake_instance_name + '/' + requests_patches['instances'].get.assert_called_once_with( + expected_endpoint, + **client_token_auth.default_request_kwargs + ) + assert rv == {} + + ########################## + # test instances.get_all # + ########################## + def test_get_all_calls_proper_endpoint(self, requests_patches, client_token_auth): + requests_patches['instances'].get.return_value = self._response_object(data=[]) + rv = client_token_auth.instances.get_all() + + expected_endpoint = client_token_auth.instances.url + requests_patches['instances'].get.assert_called_once_with( + expected_endpoint, + **client_token_auth.default_request_kwargs + ) assert rv == [] - # ------------------- - # CREATE METHOD TESTS - # ------------------- - def test_create_calls_proper_end_point(self, requestsm, create_call_data): - requestsm.post.return_value = self._response_object() - rv = self.client.instances.create(**create_call_data) - create_call_data.pop('service_type') - - expected_endpoint = self.client.api_url + 'instance/' - requestsm.post.assert_called_once_with(expected_endpoint, - data=json.dumps(create_call_data), - headers={'Content-Type': 'application/json'}, - **self.request_defaults) - assert rv == [] + # ######################### + # # test instances.create # + # ######################### + # def test_create_calls_proper_end_point(self, requests_patches, client_token_auth, + # default_create_instance_kwargs): + # requests_patches['instances'].post.return_value = self._response_object() + # rv = client_token_auth.instances.create(**default_create_instance_kwargs) + # default_create_instance_kwargs.pop('service_type') + + # expected_endpoint = client_token_auth.url + 'instance/' + # requests_patches['instances'].post.assert_called_once_with( + # expected_endpoint, + # data=json.dumps(default_create_instance_kwargs), + # **client_token_auth.default_request_kwargs + # ) + # assert rv == [] - def test_create_fails_with_bad_service_type_value(self, requestsm, create_call_data): - create_call_data['service_type'] = 'not_a_valid_service' - with pytest.raises(errors.InstancesException) as exinfo: - self.client.instances.create(**create_call_data) - - assert exinfo.value.args[0] == ('Invalid value for "service_type". ' - 'Must be one of "mongodb".') - - def test_create_fails_with_bad_version_value(self, requestsm, create_call_data): - create_call_data['version'] = 'not_a_valid_version' - with pytest.raises(errors.InstancesException) as exinfo: - self.client.instances.create(**create_call_data) - - assert exinfo.value.args[0] == ('Invalid value for "version". ' - 'Must be one of "2.4.6".') - - # ---------------- - # COMPACTION TESTS - # ---------------- - def test_compaction_calls_proper_end_point_request_compaction_false(self, requestsm): - requestsm.get.return_value = self._response_object() - instance_name = 'instance0' - rv = self.client.instances.compaction(instance_name=instance_name, - request_compaction=False) - - expected_endpoint = self.client.api_url + 'instance/' + instance_name + '/compaction/' - requestsm.get.assert_called_once_with(expected_endpoint, **self.request_defaults) - assert rv == {'data': []} - - def test_compaction_calls_proper_end_point_request_compaction_true(self, requestsm): - requestsm.post.return_value = self._response_object() - instance_name = 'instance0' - rv = self.client.instances.compaction(instance_name=instance_name, request_compaction=True) - - expected_endpoint = self.client.api_url + 'instance/' + instance_name + '/compaction/' - requestsm.post.assert_called_once_with(expected_endpoint, **self.request_defaults) - assert rv == {'data': []} - - # ------------ - # SHARDS TESTS - # ------------ - def test_shards_calls_proper_end_point_without_add_shard(self, requestsm): - requestsm.get.return_value = self._response_object() - instance_name = 'instance0' - rv = self.client.instances.shards(instance_name=instance_name, add_shard=False) - - expected_endpoint = self.client.api_url + 'instance/' + instance_name + '/shard/' - requestsm.get.assert_called_once_with(expected_endpoint, **self.request_defaults) - assert rv == {'data': []} - - def test_shards_calls_proper_end_point_with_add_shard(self, requestsm): - requestsm.post.return_value = self._response_object() - instance_name = 'instance0' - rv = self.client.instances.shards(instance_name=instance_name, add_shard=True) - - expected_endpoint = self.client.api_url + 'instance/' + instance_name + '/shard/' - requestsm.post.assert_called_once_with(expected_endpoint, **self.request_defaults) - assert rv == {'data': []} - - # ------------------------ - # CONVENIENCE METHOD TESTS - # ------------------------ - def test_instance_compaction_convenience_call_request_compaction_true(self, requestsm, - mongo_sharded_instance): - requestsm.get.return_value = self._response_object() - rv = mongo_sharded_instance.compaction() - - expected_endpoint = (self.client.api_url + 'instance/' + - mongo_sharded_instance.name + '/compaction/') - defaults = self.request_defaults - defaults.update({'hooks': - {'response': mongo_sharded_instance.client.instances._verify_auth}}) - requestsm.get.assert_called_once_with(expected_endpoint, **defaults) - assert rv == {'data': []} - - def test_instance_compaction_convenience_call_request_compaction_false(self, requestsm, - mongo_sharded_instance): - requestsm.post.return_value = self._response_object() - rv = mongo_sharded_instance.compaction(request_compaction=True) - - expected_endpoint = (self.client.api_url + 'instance/' + - mongo_sharded_instance.name + '/compaction/') - defaults = self.request_defaults - defaults.update({'hooks': - {'response': mongo_sharded_instance.client.instances._verify_auth}}) - requestsm.post.assert_called_once_with(expected_endpoint, **defaults) - assert rv == {'data': []} - - def test_instance_shards_calls_proper_end_point_without_add_shard(self, requestsm, - mongo_sharded_instance): - requestsm.get.return_value = self._response_object() - rv = mongo_sharded_instance.shards(add_shard=False) - - expected_endpoint = (self.client.api_url + 'instance/' + - mongo_sharded_instance.name + '/shard/') - defaults = self.request_defaults - defaults.update({'hooks': - {'response': mongo_sharded_instance.client.instances._verify_auth}}) - requestsm.get.assert_called_once_with(expected_endpoint, **defaults) - assert rv == {'data': []} - - def test_instance_shards_calls_proper_end_point_with_add_shard(self, requestsm, - mongo_sharded_instance): - requestsm.post.return_value = self._response_object() - rv = mongo_sharded_instance.shards(add_shard=True) - - expected_endpoint = (self.client.api_url + 'instance/' + - mongo_sharded_instance.name + '/shard/') - defaults = self.request_defaults - defaults.update({'hooks': - {'response': mongo_sharded_instance.client.instances._verify_auth}}) - requestsm.post.assert_called_once_with(expected_endpoint, **defaults) - assert rv == {'data': []} - - -class TestInstance(conftest.BaseInstanceTest): - """Tests for objectrocket.instances.Instance objects with mongodb_sharded input.""" - - def _pop_needed_key_and_assert(self, doc, needed_key): - """Pop needed_key from doc and assert KeyError during constructor run.""" - doc.pop(needed_key) - with pytest.raises(KeyError) as exinfo: - Instance(instance_document=doc, client=Client('test_user_key', 'test_pass_key')) - - assert exinfo.value.__class__ == KeyError - assert exinfo.value.args[0] == needed_key - - # ----------------- - # CONSTRUCTOR TESTS - # ----------------- - def test_constructor_passes_with_expected_document(self, _docs): - user_key, pass_key = 'test_user_key', 'test_pass_key' - inst = Instance(instance_document=_docs, - client=Client(user_key=user_key, pass_key=pass_key)) - assert isinstance(inst, Instance) - - def test_constructor_fails_with_missing_api_endpoint(self, _docs): - self._pop_needed_key_and_assert(_docs, 'api_endpoint') - - def test_constructor_fails_with_missing_connect_string(self, _docs): - self._pop_needed_key_and_assert(_docs, 'connect_string') - - def test_constructor_fails_with_missing_created(self, _docs): - self._pop_needed_key_and_assert(_docs, 'created') - - def test_constructor_fails_with_missing_name(self, _docs): - self._pop_needed_key_and_assert(_docs, 'name') - - def test_constructor_fails_with_missing_plan(self, _docs): - self._pop_needed_key_and_assert(_docs, 'plan') - - def test_constructor_passes_without_ssl_connect_string(self, _docs): - _docs.pop('ssl_connect_string', None) - inst = Instance(instance_document=_docs, client=Client('test_user_key', 'test_pass_key')) - assert isinstance(inst, Instance) - - def test_constructor_fails_with_missing_service(self, _docs): - self._pop_needed_key_and_assert(_docs, 'service') - - def test_constructor_fails_with_missing_type(self, _docs): - self._pop_needed_key_and_assert(_docs, 'type') - - def test_constructor_fails_with_missing_version(self, _docs): - self._pop_needed_key_and_assert(_docs, 'version') - - # ----------------------- - # INSTANCE PROPERTY TESTS - # ----------------------- - def test_api_endpoint_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.api_endpoint == doc['api_endpoint'] - - def test_client_property_with_valid_client(self, _instances_and_docs): - instance = _instances_and_docs[0] - assert isinstance(instance.client, Client) - - def test_mongo_sharded_connection_property(self, mongo_sharded_instance, mongo_sharded_doc): - with mock.patch('pymongo.MongoClient', return_value=None) as client: - mongo_sharded_instance.connection - - host, port = mongo_sharded_doc['connect_string'].split(':') - port = int(port) - client.assert_called_once_with(host=host, port=port) - - def test_mongo_replica_connection_property(self, mongo_replica_instance, mongo_replica_doc): - with mock.patch('pymongo.MongoReplicaSetClient', return_value=None) as client: - mongo_replica_instance.connection - - replica_set_name, member_list = mongo_replica_doc['connect_string'].split('/') - member_list = member_list.strip().strip(',') - client.assert_called_once_with(hosts_or_uri=member_list) - - def test_connect_string_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.connect_string == doc['connect_string'] - - def test_created_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.created == doc['created'] - - def test_name_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.name == doc['name'] - - def test_plan_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.plan == doc['plan'] - - def test_service_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.service == doc['service'] + # def test_create_fails_with_bad_service_type_value(self, client_token_auth, + # default_create_instance_kwargs): + # default_create_instance_kwargs['service_type'] = 'not_a_valid_service' + # with pytest.raises(errors.InstancesException) as exinfo: + # client_token_auth.instances.create(**default_create_instance_kwargs) + + # assert exinfo.value.args[0] == ('Invalid value for "service_type". ' + # 'Must be one of "mongodb".') - def test_ssl_connect_string_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.ssl_connect_string == doc.get('ssl_connect_string') - - def test_type_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.type == doc['type'] - - def test_version_property(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.version == doc['version'] + # def test_create_fails_with_bad_version_value(self, client_token_auth, + # default_create_instance_kwargs): + # default_create_instance_kwargs['version'] = 'not_a_valid_version' + # with pytest.raises(errors.InstancesException) as exinfo: + # client_token_auth.instances.create(**default_create_instance_kwargs) + + # assert exinfo.value.args[0] == ('Invalid value for "version". ' + # 'Must be one of "2.4.6".') + + + + + + + + + + + + + + + + # #################### + # # COMPACTION TESTS # + # #################### + # def test_compaction_calls_proper_end_point_request_compaction_false(self, + # requests_patches, + # client_token_auth): + # requests_patches['instances'].get.return_value = self._response_object() + # instance_name = 'instance0' + # rv = client_token_auth.instances.compaction(instance_name=instance_name, + # request_compaction=False) + + # expected_endpoint = client_token_auth.url + 'instance/' + instance_name + '/compaction/' + # requests_patches['instances'].get.assert_called_once_with( + # expected_endpoint, + # **client_token_auth.default_request_kwargs) + # assert rv == {'data': []} + + # def test_compaction_calls_proper_end_point_request_compaction_true(self, + # requests_patches, + # client_token_auth): + # requests_patches['instances'].post.return_value = self._response_object() + # instance_name = 'instance0' + # rv = client_token_auth.instances.compaction(instance_name=instance_name, + # request_compaction=True) + + # expected_endpoint = client_token_auth.url + 'instance/' + instance_name + '/compaction/' + # requests_patches['instances'].post.assert_called_once_with( + # expected_endpoint, + # **client_token_auth.default_request_kwargs) + # assert rv == {'data': []} + + # ################ + # # SHARDS TESTS # + # ################ + # def test_shards_calls_proper_end_point_without_add_shard(self, + # requests_patches, + # client_token_auth): + # requests_patches['instances'].get.return_value = self._response_object() + # instance_name = 'instance0' + # rv = client_token_auth.instances.shards(instance_name=instance_name, add_shard=False) + + # expected_endpoint = client_token_auth.url + 'instance/' + instance_name + '/shard/' + # requests_patches['instances'].get.assert_called_once_with( + # expected_endpoint, + # **client_token_auth.default_request_kwargs) + # assert rv == {'data': []} + + # def test_shards_calls_proper_end_point_with_add_shard(self, + # requests_patches, + # client_token_auth): + # requests_patches['instances'].post.return_value = self._response_object() + # instance_name = 'instance0' + # rv = client_token_auth.instances.shards(instance_name=instance_name, add_shard=True) + + # expected_endpoint = client_token_auth.url + 'instance/' + instance_name + '/shard/' + # requests_patches['instances'].post.assert_called_once_with( + # expected_endpoint, + # **client_token_auth.default_request_kwargs) + # assert rv == {'data': []} + + ############################ + # CONVENIENCE METHOD TESTS # + ############################ + # def test_instance_compaction_convenience_call_request_compaction_true(self, + # requests_patches, + # client_token_auth, + # mongo_sharded_instance): + # requests_patches['instances'].get.return_value = self._response_object() + # rv = mongo_sharded_instance.compaction() + + # expected_endpoint = (client_token_auth.url + 'instance/' + + # mongo_sharded_instance.name + '/compaction/') + # defaults = client_token_auth.default_request_kwargs + # defaults.update({'hooks': {'response': client_token_auth._verify_auth}}) + # requests_patches['instances'].get.assert_called_once_with(expected_endpoint, **defaults) + # assert rv == {'data': []} + + # def test_instance_compaction_convenience_call_request_compaction_false(self, + # requests_patches, + # client_token_auth, + # mongo_sharded_instance): + # requests_patches['instances'].post.return_value = self._response_object() + # rv = mongo_sharded_instance.compaction(request_compaction=True) + + # expected_endpoint = (client_token_auth.url + 'instance/' + + # mongo_sharded_instance.name + '/compaction/') + # defaults = client_token_auth.default_request_kwargs + # defaults.update({'hooks': {'response': client_token_auth._verify_auth}}) + # requests_patches['instances'].post.assert_called_once_with(expected_endpoint, **defaults) + # assert rv == {'data': []} + + # def test_instance_shards_calls_proper_end_point_without_add_shard(self, + # requests_patches, + # client_token_auth, + # mongo_sharded_instance): + # requests_patches['instances'].get.return_value = self._response_object() + # rv = mongo_sharded_instance.shards(add_shard=False) + + # expected_endpoint = (client_token_auth.url + 'instance/' + + # mongo_sharded_instance.name + '/shard/') + # defaults = client_token_auth.default_request_kwargs + # defaults.update({'hooks': {'response': client_token_auth._verify_auth}}) + # requests_patches['instances'].get.assert_called_once_with(expected_endpoint, **defaults) + # assert rv == {'data': []} + + # def test_instance_shards_calls_proper_end_point_with_add_shard(self, + # requests_patches, + # client_token_auth, + # mongo_sharded_instance): + # requests_patches['instances'].post.return_value = self._response_object() + # rv = mongo_sharded_instance.shards(add_shard=True) + + # expected_endpoint = (client_token_auth.url + 'instance/' + + # mongo_sharded_instance.name + '/shard/') + # defaults = client_token_auth.default_request_kwargs + # defaults.update({'hooks': {'response': client_token_auth._verify_auth}}) + # requests_patches['instances'].post.assert_called_once_with(expected_endpoint, **defaults) + # assert rv == {'data': []} + + +# class TestInstance(conftest.InstancesHarness): +# """Tests for objectrocket.instances.Instance objects with mongodb_sharded input.""" + +# def _pop_needed_key_and_assert(self, doc, needed_key): +# """Pop needed_key from doc and assert KeyError during constructor run.""" +# doc.pop(needed_key) +# with pytest.raises(KeyError) as exinfo: +# instances.BaseInstance(instance_document=doc, client=Client('test_user_key', +# 'test_pass_key')) + +# assert exinfo.value.__class__ == KeyError +# assert exinfo.value.args[0] == needed_key + +# ##################### +# # CONSTRUCTOR TESTS # +# ##################### +# def test_constructor_passes_with_expected_document(self, requests_patches, _docs): +# user_key, pass_key = 'test_user_key', 'test_pass_key' +# inst = instances.BaseInstance(instance_document=_docs, +# client=Client(user_key=user_key, pass_key=pass_key)) +# assert isinstance(inst, instances.BaseInstance) + +# def test_constructor_fails_with_missing_api_endpoint(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'api_endpoint') + +# def test_constructor_fails_with_missing_connect_string(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'connect_string') + +# def test_constructor_fails_with_missing_created(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'created') + +# def test_constructor_fails_with_missing_name(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'name') + +# def test_constructor_fails_with_missing_plan(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'plan') + +# def test_constructor_passes_without_ssl_connect_string(self, requests_patches, _docs): +# _docs.pop('ssl_connect_string', None) +# inst = instances.BaseInstance(instance_document=_docs, client=Client('test_user_key', +# 'test_pass_key')) +# assert isinstance(inst, instances.BaseInstance) + +# def test_constructor_fails_with_missing_service(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'service') + +# def test_constructor_fails_with_missing_type(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'type') + +# def test_constructor_fails_with_missing_version(self, requests_patches, _docs): +# self._pop_needed_key_and_assert(_docs, 'version') + +# ########################### +# # INSTANCE PROPERTY TESTS # +# ########################### +# def test_api_endpoint_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.api_endpoint == doc['api_endpoint'] + +# def test_client_property_with_valid_client(self, _instances_and_docs): +# instance = _instances_and_docs[0] +# assert isinstance(instance.client, Client) + +# def test_mongo_sharded_connection_property(self, mongo_sharded_instance, mongo_sharded_doc): +# with mock.patch('pymongo.MongoClient', return_value=None) as client: +# mongo_sharded_instance.connection + +# host, port = mongo_sharded_doc['connect_string'].split(':') +# port = int(port) +# client.assert_called_once_with(host=host, port=port) + +# def test_mongo_replica_connection_property(self, mongo_replica_instance, mongo_replica_doc): +# with mock.patch('pymongo.MongoReplicaSetClient', return_value=None) as client: +# mongo_replica_instance.connection + +# replica_set_name, member_list = mongo_replica_doc['connect_string'].split('/') +# member_list = member_list.strip().strip(',') +# client.assert_called_once_with(hosts_or_uri=member_list) + +# def test_connect_string_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.connect_string == doc['connect_string'] + +# def test_created_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.created == doc['created'] + +# def test_name_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.name == doc['name'] + +# def test_plan_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.plan == doc['plan'] + +# def test_service_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.service == doc['service'] - # --------------------- - # INSTANCE METHOD TESTS - # --------------------- - def test_to_dict_method(self, _instances_and_docs): - instance, doc = _instances_and_docs[0], _instances_and_docs[1] - assert instance.to_dict() == doc +# def test_ssl_connect_string_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.ssl_connect_string == doc.get('ssl_connect_string') + +# def test_type_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.type == doc['type'] + +# def test_version_property(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.version == doc['version'] + +# ######################### +# # INSTANCE METHOD TESTS # +# ######################### +# def test_to_dict_method(self, _instances_and_docs): +# instance, doc = _instances_and_docs[0], _instances_and_docs[1] +# assert instance.to_dict() == doc diff --git a/tests/test_operations.py b/tests/test_operations.py index b1f1e5e..dfac46d 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -2,47 +2,36 @@ import pytest from objectrocket import errors -from objectrocket.client import Client +from objectrocket import client from objectrocket.operations import BaseOperationsLayer from tests import conftest -class TestBaseOperationsLayer(conftest.BaseClientTest): - """Tests for objectrocket.operations.BaseOperationsLayer object.""" +class TestBaseOperationsLayer(conftest.OperationsHarness, conftest.GenericFixtures): + """Tests for objectrocket.operations.BaseOperationsLayer.""" - @pytest.fixture - def obj(self): - class Obj(object): - pass + def test_class_instantiation(self, client_token_auth, obj): + assert BaseOperationsLayer(client_instance=client_token_auth) - return Obj() + def test_client_is_properly_embedded(self, client_token_auth): + inst = BaseOperationsLayer(client_instance=client_token_auth) + assert isinstance(inst.client, client.Client) - def test_instantiation(self, obj): - assert BaseOperationsLayer(self.client) - - def test_client_is_given_client(self): - inst = BaseOperationsLayer(self.client) - assert isinstance(inst._client, Client) - - def test_client_returns_none_when_client_is_invalid(self): - inst = BaseOperationsLayer('not_a_valid_client') - assert inst._client is None - - def test_verify_auth_returns_none(self, obj): + def test_verify_auth_returns_none_with_status_code_200(self, client_token_auth, obj): obj.status_code = 200 - inst = BaseOperationsLayer(self.client) + inst = BaseOperationsLayer(client_instance=client_token_auth) assert inst._verify_auth(obj) is None - def test_verify_auth_raises_if_401_status_code(self, obj): + def test_verify_auth_raises_with_status_code_401(self, client_token_auth, obj): obj.status_code = 401 obj.request = self.obj() obj.request.method = 'TEST' obj.request.path_url = '/TEST/PATH/' - inst = BaseOperationsLayer(self.client) + inst = BaseOperationsLayer(client_token_auth) with pytest.raises(errors.AuthFailure) as exinfo: - inst._verify_auth(obj) is obj + inst._verify_auth(obj) assert exinfo.value.args[0] == ('Received response code 401 from TEST /TEST/PATH/. ' - 'Keypair used: {}:{}' - ''.format(self.client.user_key, self.client.pass_key)) + 'Keypair used: {}:{}'.format(client_token_auth.user_key, + client_token_auth.pass_key)) diff --git a/tox.ini b/tox.ini index 7129432..4d8bb84 100644 --- a/tox.ini +++ b/tox.ini @@ -28,14 +28,17 @@ commands = python setup.py bdist_wheel {posargs} python {toxinidir}/scripts/build_cleanup.py {toxinidir} [testenv:docs] -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/dev-requirements.txt -commands = python setup.py build_sphinx {posargs} +commands = python {toxinidir}/scripts/check_docs.py --no-fail \ + --pytree {toxinidir}/objectrocket \ + --doctree {toxinidir}/docs/source \ + --doc-ignores API.rst,Installation.rst,Tutorial.rst + python {toxinidir}/setup.py build_sphinx {posargs} [testenv:pep8] deps = flake8 commands = {envbindir}/flake8 {posargs} {toxinidir} [flake8] -exclude = *.egg-info,.venv,.git,.tox,build,dist,docs -max-line-length = 99 +select = E123 +max-line-length = 100 +exclude = *.egg-info,.git,.tox,build,dist,docs