diff --git a/.travis.yml b/.travis.yml index fdfceff5..52fda467 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ sudo: false language: python python: - 2.7 - - 3.3 - 3.4 install: - pip install -r requirements.txt -r requirements-dev.txt diff --git a/nailgun/entities.py b/nailgun/entities.py index 4e8d16a9..c9faedb3 100644 --- a/nailgun/entities.py +++ b/nailgun/entities.py @@ -21,8 +21,13 @@ workings of entity classes. """ +import random from datetime import datetime +from sys import version_info + from fauxfactory import gen_alphanumeric +from packaging.version import Version + from nailgun import client, entity_fields from nailgun.entity_mixins import ( Entity, @@ -33,11 +38,9 @@ EntityUpdateMixin, MissingValueError, _poll_task, + to_json_serializable as to_json ) -from packaging.version import Version -import random -from sys import version_info if version_info.major == 2: # pragma: no cover from httplib import ACCEPTED, NO_CONTENT # pylint:disable=import-error else: # pragma: no cover @@ -88,7 +91,7 @@ class APIResponseError(Exception): """Indicates an error if response returns unexpected result.""" -def _handle_response(response, server_config, synchronous=False): +def _handle_response(response, server_config, synchronous=False, timeout=None): """Handle a server's response in a typical fashion. Do the following: @@ -105,11 +108,14 @@ def _handle_response(response, server_config, synchronous=False): :mod:`nailgun.client` or the requests library. :param server_config: A `nailgun.config.ServerConfig` object. :param synchronous: Should this function poll the server? + :param timeout: Maximum number of seconds to wait until timing out. + Defaults to ``nailgun.entity_mixins.TASK_TIMEOUT``. """ response.raise_for_status() if synchronous is True and response.status_code == ACCEPTED: - return ForemanTask(server_config, id=response.json()['id']).poll() + return ForemanTask( + server_config, id=response.json()['id']).poll(timeout=timeout) if response.status_code == NO_CONTENT: return if 'application/json' in response.headers.get('content-type', '').lower(): @@ -190,6 +196,18 @@ def _get_version(server_config): return getattr(server_config, 'version', Version('1!0')) +def to_json_serializable(obj): + """Just an alias to entity_mixins.to_json_seriazable so this module can + be used as a facade + + :param obj: entity or any json serializable object + + :return: serializable object + + """ + return to_json(obj) + + class ActivationKey( Entity, EntityCreateMixin, @@ -206,13 +224,17 @@ def __init__(self, server_config=None, **kwargs): 'description': entity_fields.StringField(), 'environment': entity_fields.OneToOneField(LifecycleEnvironment), 'host_collection': entity_fields.OneToManyField(HostCollection), - 'max_content_hosts': entity_fields.IntegerField(), - 'name': entity_fields.StringField(required=True), + 'max_hosts': entity_fields.IntegerField(), + 'name': entity_fields.StringField( + required=True, + str_type='alpha', + length=(6, 12), + ), 'organization': entity_fields.OneToOneField( Organization, required=True, ), - 'unlimited_content_hosts': entity_fields.BooleanField(), + 'unlimited_hosts': entity_fields.BooleanField(), } self._meta = { 'api_path': 'katello/api/v2/activation_keys', diff --git a/nailgun/entity_mixins.py b/nailgun/entity_mixins.py index 3aa5d474..3ddb2988 100644 --- a/nailgun/entity_mixins.py +++ b/nailgun/entity_mixins.py @@ -1,10 +1,14 @@ # -*- encoding: utf-8 -*- """Defines a set of mixins that provide tools for interacting with entities.""" +import json as std_json from collections import Iterable +from datetime import date, datetime + from fauxfactory import gen_choice from inflection import pluralize from nailgun import client, config -from nailgun.entity_fields import IntegerField, OneToManyField, OneToOneField +from nailgun.entity_fields import ( + IntegerField, OneToManyField, OneToOneField, ListField) import threading import time @@ -76,9 +80,10 @@ def _poll_task(task_id, server_config, poll_rate=None, timeout=None): timeout = TASK_TIMEOUT # Implement the timeout. - def raise_task_timeout(): + def raise_task_timeout(): # pragma: no cover """Raise a KeyboardInterrupt exception in the main thread.""" thread.interrupt_main() + timer = threading.Timer(timeout, raise_task_timeout) # Poll until the task finishes. The timeout prevents an infinite loop. @@ -92,7 +97,7 @@ def raise_task_timeout(): if task_info['state'] in ('paused', 'stopped'): break time.sleep(poll_rate) - except KeyboardInterrupt: + except KeyboardInterrupt: # pragma: no cover # raise_task_timeout will raise a KeyboardInterrupt when the timeout # expires. Catch the exception and raise TaskTimedOutError raise TaskTimedOutError( @@ -191,6 +196,15 @@ def _payload(fields, values): values[field_name + '_ids'] = [ entity.id for entity in values.pop(field_name) ] + elif isinstance(field, ListField): + def parse(obj): + """parse obj payload if it is an Entity""" + if isinstance(obj, Entity): + return _payload(obj.get_fields(), obj.get_values()) + return obj + + values[field_name] = [ + parse(obj) for obj in values[field_name]] return values @@ -485,12 +499,11 @@ def get_fields(self): def get_values(self): """Return a copy of field values on the current object. - This method is almost identical to ``vars(self).copy()``. However, only - instance attributes that correspond to a field are included in the - returned dict. + This method is almost identical to ``vars(self).copy()``. However, + only instance attributes that correspond to a field are included in + the returned dict. :return: A dict mapping field names to user-provided values. - """ attrs = vars(self).copy() attrs.pop('_server_config') @@ -510,6 +523,63 @@ def __repr__(self): ) ) + def to_json(self): + r"""Create a JSON encoded string with Entity properties. Ex: + + >>> from nailgun import entities, config + >>> kwargs = { + ... 'id': 1, + ... 'name': 'Nailgun Org', + ... } + >>> org = entities.Organization(config.ServerConfig('foo'), \*\*kwargs) + >>> org.to_json() + '{"id": 1, "name": "Nailgun Org"}' + + :return: str + """ + return std_json.dumps(self.to_json_dict()) + + def to_json_dict(self): + """Create a dct with Entity properties for json encoding. + It can be overridden by subclasses for each standard serialization + doesn't work. By default it call _to_json_dict on OneToOne fields + and build a list calling the same method on each object on OneToMany + fields. + + :return: dct + """ + fields, values = self.get_fields(), self.get_values() + json_dct = {} + for field_name, field in fields.items(): + if field_name in values: + value = values[field_name] + if value is None: + json_dct[field_name] = None + # This conditions is needed because some times you get + # None on an OneToOneField what lead to an error + # on bellow condition, e.g., calling value.to_json_dict() + # when value is None + elif isinstance(field, OneToOneField): + json_dct[field_name] = value.to_json_dict() + elif isinstance(field, OneToManyField): + json_dct[field_name] = [ + entity.to_json_dict() for entity in value + ] + else: + json_dct[field_name] = to_json_serializable(value) + return json_dct + + def __eq__(self, other): + """Compare two entities based on their properties. Even nested + objects are considered for equality + + :param other: entity to compare self to + :return: boolean indicating if entities are equal or not + """ + if other is None: + return False + return self.to_json_dict() == other.to_json_dict() + class EntityDeleteMixin(object): """This mixin provides the ability to delete an entity. @@ -1298,3 +1368,26 @@ def search_filter(entities, filters): if getattr(entity, field_name) == field_value ] return filtered + + +def to_json_serializable(obj): + """ Transforms obj into a json serializable object. + + :param obj: entity or any json serializable object + + :return: serializable object + + """ + if isinstance(obj, Entity): + return obj.to_json_dict() + + if isinstance(obj, dict): + return {k: to_json_serializable(v) for k, v in obj.items()} + elif isinstance(obj, (list, tuple)): + return [to_json_serializable(v) for v in obj] + elif isinstance(obj, datetime): + return obj.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(obj, date): + return obj.strftime('%Y-%m-%d') + + return obj diff --git a/tests/test_entities.py b/tests/test_entities.py index 0a236d41..48c57636 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,6 +1,7 @@ # -*- encoding: utf-8 -*- """Tests for :mod:`nailgun.entities`.""" -from datetime import datetime +import json +from datetime import datetime, date from fauxfactory import gen_integer, gen_string from nailgun import client, config, entities from nailgun.entity_mixins import ( @@ -29,6 +30,14 @@ # `nailgun.entities` and the Satellite API. +def make_entity(cls, **kwargs): + """Helper function to create entity with dummy ServerConfig""" + cfg = config.ServerConfig( + url='https://foo.bar', verify=False, + auth=('foo', 'bar')) + return cls(cfg, **kwargs) + + def _get_required_field_names(entity): """Get the names of all required fields from an entity. @@ -1761,6 +1770,18 @@ def test_api_response_error(self): entities._get_org(*self.args) self.assertEqual(search.call_count, 1) + def test_to_json(self): + """json serialization""" + kwargs = { + 'id': 1, + 'description': 'some description', + 'label': 'some label', + 'name': 'Nailgun Org', + 'title': 'some title', + } + org = entities.Organization(config.ServerConfig('foo'), **kwargs) + self.assertEqual(kwargs, json.loads(org.to_json())) + class HandleResponseTestCase(TestCase): """Test ``nailgun.entities._handle_response``.""" @@ -2025,3 +2046,70 @@ def test_systempackage(self): """ with self.assertRaises(DeprecationWarning): entities.SystemPackage(self.cfg_620) + + +class JsonSerializableTestCase(TestCase): + """Test regarding Json serializable on different object""" + + def test_regular_objects(self): + """Checking regular objects transformation""" + lst = [[1, 0.3], {'name': 'foo'}] + self.assertEqual(lst, entities.to_json_serializable(lst)) + + def test_nested_entities(self): + """Check nested entities serialization""" + env_kwargs = {'id': 1, 'name': 'env'} + env = make_entity(entities.Environment, **env_kwargs) + + location_kwargs = {'name': 'loc'} + locations = [make_entity(entities.Location, **location_kwargs)] + + hostgroup_kwargs = {'id': 2, 'name': 'hgroup'} + hostgroup = make_entity( + entities.HostGroup, + location=locations, + **hostgroup_kwargs) + + hostgroup_kwargs['location'] = [location_kwargs] + + combinations = [ + {'environment_id': 3, 'hostgroup_id': 4}, + make_entity(entities.TemplateCombination, + hostgroup=hostgroup, + environment=env) + ] + + expected_combinations = [ + {'environment_id': 3, 'hostgroup_id': 4}, + {'environment': env_kwargs, 'hostgroup': hostgroup_kwargs} + ] + + cfg_kwargs = {'id': 5, 'snippet': False, 'template': 'cat'} + cfg_template = make_entity( + entities.ConfigTemplate, + template_combinations=combinations, + **cfg_kwargs) + + cfg_kwargs['template_combinations'] = expected_combinations + self.assertDictEqual(cfg_kwargs, + entities.to_json_serializable(cfg_template)) + + def test_date_field(self): + """Check date field serialization""" + + self.assertEqual( + '2016-09-20', + entities.to_json_serializable(date(2016, 9, 20)) + ) + + def test_boolean_datetime_float(self): + """Check serialization for boolean, datetime and float fields""" + kwargs = { + 'pending': True, + 'progress': 0.25, + 'started_at': datetime(2016, 11, 20, 1, 2, 3) + } + task = make_entity( + entities.ForemanTask, **kwargs) + kwargs['started_at'] = '2016-11-20 01:02:03' + self.assertDictEqual(kwargs, entities.to_json_serializable(task)) diff --git a/tests/test_entity_mixins.py b/tests/test_entity_mixins.py index 70b63ec7..99123158 100644 --- a/tests/test_entity_mixins.py +++ b/tests/test_entity_mixins.py @@ -7,6 +7,7 @@ from nailgun import client, config, entity_mixins from nailgun.entity_fields import ( IntegerField, + ListField, OneToManyField, OneToOneField, StringField, @@ -60,6 +61,25 @@ def __init__(self, server_config=None, **kwargs): super(SampleEntityTwo, self).__init__(server_config, **kwargs) +class SampleEntityThree(entity_mixins.Entity): + """An entity with foreign key fields as One to One and ListField. + + This class has a :class:`nailgun.entity_fields.OneToOneField` called + "one_to_one" pointing to :class:`tests.test_entity_mixins.SampleEntityTwo`. + + This class has a :class:`nailgun.entity_fields.ListField` called "list" + containing instances of :class:`tests.test_entity_mixins.SampleEntity`. + + """ + + def __init__(self, server_config=None, **kwargs): + self._fields = { + 'one_to_one': OneToOneField(SampleEntityTwo), + 'list': ListField() + } + super(SampleEntityThree, self).__init__(server_config, **kwargs) + + class EntityWithCreate(entity_mixins.Entity, entity_mixins.EntityCreateMixin): """Inherits from :class:`nailgun.entity_mixins.EntityCreateMixin`.""" @@ -343,6 +363,70 @@ def test_bad_value_error(self): with self.assertRaises(entity_mixins.BadValueError): SampleEntityTwo(self.cfg, one_to_many=1) + def test_eq_none(self): + """Test method ``nailgun.entity_mixins.Entity.__eq__`` against None + + Assert that ``__eq__`` returns False when compared to None. + + """ + alice = SampleEntity(self.cfg, id=1, name='Alice') + self.assertFalse(alice.__eq__(None)) + + def test_eq(self): + """Test method ``nailgun.entity_mixins.Entity.__eq__``. + + Assert that ``__eq__`` works comparing all attributes, even from + nested structures. + + """ + # Testing simple properties + alice = SampleEntity(self.cfg, id=1, name='Alice') + alice_clone = SampleEntity(self.cfg, id=1, name='Alice') + self.assertEqual(alice, alice_clone) + + alice_id_2 = SampleEntity(self.cfg, id=2, name='Alice') + self.assertNotEqual(alice, alice_id_2) + + # Testing OneToMany nested objects + + john = SampleEntityTwo(self.cfg, one_to_many=[alice, alice_id_2]) + john_clone = SampleEntityTwo(self.cfg, one_to_many=[alice, alice_id_2]) + self.assertEqual(john, john_clone) + + john_different_order = SampleEntityTwo(self.cfg, one_to_many=[ + alice_id_2, alice, + ]) + self.assertNotEqual(john, john_different_order) + + john_missing_alice = SampleEntityTwo(self.cfg, one_to_many=[alice]) + self.assertNotEqual(john, john_missing_alice) + + john_without_alice = SampleEntityTwo(self.cfg) + self.assertNotEqual(john, john_without_alice) + + # Testing OneToOne nested objects + + mary = SampleEntityThree(self.cfg, one_to_one=john) + mary_clone = SampleEntityThree( + self.cfg, one_to_one=john_clone) + self.assertEqual(mary, mary_clone) + + mary_different = SampleEntityThree( + self.cfg, one_to_one=john_different_order) + self.assertNotEqual(mary, mary_different) + + mary_none_john = SampleEntityThree(self.cfg, one_to_one=None) + mary_none_john.to_json_dict() + self.assertNotEqual(mary, mary_none_john) + + # Testing List nested objects + # noqa pylint:disable=attribute-defined-outside-init + mary.list = [alice] + self.assertNotEqual(mary, mary_clone) + # noqa pylint:disable=attribute-defined-outside-init + mary_clone.list = [alice_clone] + self.assertEqual(mary, mary_clone) + def test_repr_v1(self): """Test method ``nailgun.entity_mixins.Entity.__repr__``. @@ -545,6 +629,7 @@ def setUpClass(cls): ``test_entity`` is a class having one to one and one to many fields. """ + class TestEntity(entity_mixins.Entity, entity_mixins.EntityReadMixin): """An entity with several different types of fields."""