From b5cc2f6dd37e04762b68bd48f145ee0a16dc994f Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 27 Jun 2014 09:22:01 -0700 Subject: [PATCH 001/118] Clean the slate. --- .travis.yml | 6 +- AUTHORS.md | 4 +- CHANGELOG.md | 26 - LICENSE | 2 +- README.md | 116 +--- armet/__init__.py | 14 - armet/_version.py | 5 - armet/attributes/__init__.py | 22 - armet/attributes/attribute.py | 264 --------- armet/attributes/decimal.py | 37 -- armet/attributes/primitive.py | 84 --- armet/attributes/temporal.py | 98 ---- armet/attributes/timezone.py | 43 -- armet/attributes/uuid.py | 69 --- armet/authentication.py | 94 --- armet/authorization.py | 102 ---- armet/connectors/__init__.py | 8 - armet/connectors/bottle/__init__.py | 0 armet/connectors/bottle/http.py | 152 ----- armet/connectors/bottle/resources.py | 40 -- armet/connectors/django/__init__.py | 0 armet/connectors/django/http.py | 179 ------ armet/connectors/django/resources.py | 255 -------- armet/connectors/flask/__init__.py | 0 armet/connectors/flask/http.py | 158 ----- armet/connectors/flask/resources.py | 121 ---- armet/connectors/sqlalchemy/__init__.py | 0 armet/connectors/sqlalchemy/authorization.py | 47 -- armet/connectors/sqlalchemy/resources.py | 430 -------------- armet/decorators.py | 117 ---- armet/deserializers/__init__.py | 10 - armet/deserializers/base.py | 22 - armet/deserializers/json.py | 28 - armet/deserializers/url.py | 52 -- armet/exceptions.py | 24 - armet/helpers.py | 21 - armet/http/__init__.py | 31 - armet/http/exceptions.py | 138 ----- armet/http/request.py | 267 --------- armet/http/response.py | 476 --------------- armet/media_types.py | 61 -- armet/pagination.py | 100 ---- armet/query/__init__.py | 12 - armet/query/constants.py | 98 ---- armet/query/parser.py | 366 ------------ armet/relationship.py | 33 -- armet/resources/__init__.py | 11 - armet/resources/managed/__init__.py | 15 - armet/resources/managed/base.py | 498 ---------------- armet/resources/managed/meta.py | 84 --- armet/resources/managed/options.py | 153 ----- armet/resources/managed/request.py | 12 - armet/resources/model/__init__.py | 14 - armet/resources/model/base.py | 17 - armet/resources/model/meta.py | 41 -- armet/resources/model/options.py | 18 - armet/resources/resource/__init__.py | 14 - armet/resources/resource/base.py | 580 ------------------- armet/resources/resource/meta.py | 192 ------ armet/resources/resource/options.py | 356 ------------ armet/serializers/__init__.py | 10 - armet/serializers/base.py | 45 -- armet/serializers/json.py | 33 -- armet/serializers/url.py | 30 - armet/test.py | 111 ---- armet/utils/__init__.py | 15 - armet/utils/decorators.py | 30 - armet/utils/functional.py | 40 -- armet/utils/package.py | 12 - armet/utils/string.py | 13 - setup.cfg | 2 - setup.py | 65 --- tests/__init__.py | 0 tests/connectors/__init__.py | 0 tests/connectors/base.py | 60 -- tests/connectors/bottle.py | 36 -- tests/connectors/conftest.py | 15 - tests/connectors/data.json | 1 - tests/connectors/django/__init__.py | 54 -- tests/connectors/django/models.py | 12 - tests/connectors/django/settings.py | 153 ----- tests/connectors/django/urls.py | 14 - tests/connectors/flask.py | 33 -- tests/connectors/resources.py | 474 --------------- tests/connectors/sqlalchemy.py | 73 --- tests/connectors/test_access.py | 147 ----- tests/connectors/test_attributes.py | 135 ----- tests/connectors/test_delete.py | 14 - tests/connectors/test_get.py | 197 ------- tests/connectors/test_options.py | 120 ---- tests/connectors/test_post.py | 66 --- tests/connectors/test_put.py | 37 -- tests/connectors/test_resource.py | 339 ----------- tests/connectors/test_validation.py | 53 -- tests/connectors/utils.py | 18 - tests/test_deserializers.py | 100 ---- tests/test_parse.py | 175 ------ tests/test_query.py | 105 ---- tests/test_serializers.py | 123 ---- tests/test_utils.py | 59 -- 100 files changed, 7 insertions(+), 9249 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 armet/__init__.py delete mode 100644 armet/_version.py delete mode 100644 armet/attributes/__init__.py delete mode 100644 armet/attributes/attribute.py delete mode 100644 armet/attributes/decimal.py delete mode 100644 armet/attributes/primitive.py delete mode 100644 armet/attributes/temporal.py delete mode 100644 armet/attributes/timezone.py delete mode 100644 armet/attributes/uuid.py delete mode 100644 armet/authentication.py delete mode 100644 armet/authorization.py delete mode 100644 armet/connectors/__init__.py delete mode 100644 armet/connectors/bottle/__init__.py delete mode 100644 armet/connectors/bottle/http.py delete mode 100644 armet/connectors/bottle/resources.py delete mode 100644 armet/connectors/django/__init__.py delete mode 100644 armet/connectors/django/http.py delete mode 100644 armet/connectors/django/resources.py delete mode 100644 armet/connectors/flask/__init__.py delete mode 100644 armet/connectors/flask/http.py delete mode 100644 armet/connectors/flask/resources.py delete mode 100644 armet/connectors/sqlalchemy/__init__.py delete mode 100644 armet/connectors/sqlalchemy/authorization.py delete mode 100644 armet/connectors/sqlalchemy/resources.py delete mode 100644 armet/decorators.py delete mode 100644 armet/deserializers/__init__.py delete mode 100644 armet/deserializers/base.py delete mode 100644 armet/deserializers/json.py delete mode 100644 armet/deserializers/url.py delete mode 100644 armet/exceptions.py delete mode 100644 armet/helpers.py delete mode 100644 armet/http/__init__.py delete mode 100644 armet/http/exceptions.py delete mode 100644 armet/http/request.py delete mode 100644 armet/http/response.py delete mode 100644 armet/media_types.py delete mode 100644 armet/pagination.py delete mode 100644 armet/query/__init__.py delete mode 100644 armet/query/constants.py delete mode 100644 armet/query/parser.py delete mode 100644 armet/relationship.py delete mode 100644 armet/resources/__init__.py delete mode 100644 armet/resources/managed/__init__.py delete mode 100644 armet/resources/managed/base.py delete mode 100644 armet/resources/managed/meta.py delete mode 100644 armet/resources/managed/options.py delete mode 100644 armet/resources/managed/request.py delete mode 100644 armet/resources/model/__init__.py delete mode 100644 armet/resources/model/base.py delete mode 100644 armet/resources/model/meta.py delete mode 100644 armet/resources/model/options.py delete mode 100644 armet/resources/resource/__init__.py delete mode 100644 armet/resources/resource/base.py delete mode 100644 armet/resources/resource/meta.py delete mode 100644 armet/resources/resource/options.py delete mode 100644 armet/serializers/__init__.py delete mode 100644 armet/serializers/base.py delete mode 100644 armet/serializers/json.py delete mode 100644 armet/serializers/url.py delete mode 100644 armet/test.py delete mode 100644 armet/utils/__init__.py delete mode 100644 armet/utils/decorators.py delete mode 100644 armet/utils/functional.py delete mode 100644 armet/utils/package.py delete mode 100644 armet/utils/string.py delete mode 100644 setup.cfg delete mode 100644 tests/__init__.py delete mode 100644 tests/connectors/__init__.py delete mode 100644 tests/connectors/base.py delete mode 100644 tests/connectors/bottle.py delete mode 100644 tests/connectors/conftest.py delete mode 100644 tests/connectors/data.json delete mode 100644 tests/connectors/django/__init__.py delete mode 100644 tests/connectors/django/models.py delete mode 100644 tests/connectors/django/settings.py delete mode 100644 tests/connectors/django/urls.py delete mode 100644 tests/connectors/flask.py delete mode 100644 tests/connectors/resources.py delete mode 100644 tests/connectors/sqlalchemy.py delete mode 100644 tests/connectors/test_access.py delete mode 100644 tests/connectors/test_attributes.py delete mode 100644 tests/connectors/test_delete.py delete mode 100644 tests/connectors/test_get.py delete mode 100644 tests/connectors/test_options.py delete mode 100644 tests/connectors/test_post.py delete mode 100644 tests/connectors/test_put.py delete mode 100644 tests/connectors/test_resource.py delete mode 100644 tests/connectors/test_validation.py delete mode 100644 tests/connectors/utils.py delete mode 100644 tests/test_deserializers.py delete mode 100644 tests/test_parse.py delete mode 100644 tests/test_query.py delete mode 100644 tests/test_serializers.py delete mode 100644 tests/test_utils.py diff --git a/.travis.yml b/.travis.yml index 99838c3..de84a6e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,14 @@ language: python python: - - '2.7' - '3.3' -# - 'pypy' # Install system dependencies before_install: - - 'travis_retry sudo apt-get install python-dev libevent-dev' + - 'travis_retry sudo apt-get install python-dev' # Install python dependencies install: - - 'travis_retry pip install -U -e ".[test]" --use-mirrors' + - 'travis_retry pip install -Ue . --use-mirrors' - 'travis_retry pip install coveralls --use-mirrors' # Blacklist legacy branches as they don't have operating tests diff --git a/AUTHORS.md b/AUTHORS.md index ba1b2e5..6d317c7 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,18 +1,18 @@ The python implementation of **Armet** is currently maintained by: - Ryan Leckey ([Concordus Applications][]) — [@mehcode][] - Erich Healy ([Concordus Applications][]) — [@CactusCommander][] -- Hardy Jones ([Concordus Applications][]) — [@joneshf][] [Concordus Applications]: http://www.concordusapps.com/ [@mehcode]: http://github.com/mehcode [@CactusCommander]: http://github.com/CactusCommander -[@joneshf]: http://github.com/joneshf Additional contributors (in no particular order): - James Miles — [@flyingbluejay][] - Adam Voliva ([Concordus Applications][]) — [@avoliva][] - Taylor Stackpole ([Concordus Applications][]) — [@taystack][] +- Hardy Jones ([Concordus Applications][]) — [@joneshf][] [@flyingbluejay]: http://github.com/flyingbluejay [@avoliva]: http://github.com/avoliva [@taystack]: http://github.com/taystack +[@joneshf]: http://github.com/joneshf diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 055f328..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -## 0.4.0 - - - Catch assertions and value errors during the attribute cleaning cycle and send the messages back as a serialized 400 response to the client. - - - Removed deprecated `include` resource option for declaring attributes. - - - Moved attributes out of `armet.resources`. `armet.resources.*Attribute` becomes `armet.attributes.*Attribute`. - - - Facilitate properties defined for attributes: - - `read` (default `True`) — attribute may be accessed directly (eg. `GET /resource/attribute` or `GET /resource/1/attribute`). Note that setting this to `False` will still show the attribute in the body of `GET /resource`. Set `include` to `False` to hide it from the body. - - - `write` (default `True`) ­— attribute may be modified through any operation (`POST`, `PUT`, or `PATCH` on the body or directly). This can be set to `True`, `False` - - - `include` (default `True`) — attribute is included in the response body. This can be set to `True`, `False` - - - `null` (default: `True`) — attribute can accept a `null` value. This is only checked (for obvious reasons) at modification operations and results in a validation error. This can be set to `True`, `False` - - - `required` (default: `False`) — attribute must be present in the body. This is only checked (for obvious reasons) at modification operations and results in a validation error. This can be set to `True`, `False` - - - `collection` (default: `False`) — attribute is to be treated as a collection. This means that the response body will always be at least an array of one and any operations (such as pagination) that are defined for collections are defined for it. - - - Added `name` parameter to attributes to override name in resource model (normally derived from the python name). - - - Added simple path traversal for attributes (eg. `GET /path/attribute` or `GET /path/slug/attribute`). - - - Changed `slug` resource option to a string that maps to an existing attribute by their name. diff --git a/LICENSE b/LICENSE index bd41ff6..dfef7c5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright © 2012-2013 by Concordus Applications, Inc. +Copyright © 2012-2014 by Concordus Applications, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3416e80..49822c8 100644 --- a/README.md +++ b/README.md @@ -13,60 +13,16 @@ that takes more time to develop then its underlying functionality. **Armet** enables you, the developer, to focus on developing the application behind the interface. -## Connectors - -**Armet** exists as an abstraction layer above your -web server framework and therefore can run in most -frameworks and envrionments using connectors. - -There are two kinds of connectors: `http` and `model`. The `http` connector -is in charge of facilitating the request / response cycle. The `model` -connector is in charge of facilitating the database access layer for RESTful -resources that are bound to a declarative model. For the most part these -connectors can be mixed and changed at a per-resource level (eg. one resource -may use `django` for its http connector and `sqlalchemy` for its model -and another may still use `django` for its http connector but reuse it for -its model one as well). - -### Request / response (http) - -###### [Django](https://www.djangoproject.com/) `>= 1.4` -> The Web framework for perfectionists (with deadlines). -> Django makes it easier to build better Web apps more quickly and -> with less code. - -###### [Flask](http://flask.pocoo.org/) -> Flask is a microframework for Python based on Werkzeug, -> Jinja 2 and good intentions. - -###### [Bottle](http://bottlepy.org/docs/dev/) -> Bottle is a fast, simple and lightweight -> WSGI micro web-framework for Python. - -### Database access (model) - -###### [Django](https://www.djangoproject.com/) `>= 1.4` -> The Web framework for perfectionists (with deadlines). -Django makes it easier to build better Web apps more quickly and with less code. - -###### [SQLAlchemy](http://www.sqlalchemy.org/) `>= 0.7` -> SQLAlchemy is the Python SQL toolkit and Object Relational Mapper that -gives application developers the full power and flexibility of SQL. - ## Installation ### Automated -1. **Armet** is not yet listed on [PyPI](https://pypi.python.org/pypi/) - but can be installed by directly referencing its git url with `pip` - or `easy_install`. +1. **Armet** can be installed with `pip` or `easy_install`. ```sh - pip install git+git://github.com/armet/python-armet.git + pip install armet ``` - > Examples are not included when installing using `pip` or `easy_install`. - ### Manual 1. Clone the **Armet** repository to your local computer. @@ -75,23 +31,6 @@ gives application developers the full power and flexibility of SQL. git clone git://github.com/armet/python-armet.git ``` - To grab a specific version you can use the `-b` argument to specify - a branch, which can include a specific tag - as well (eg. `versions/0.4.x` or `0.4.1`). - - ```sh - git clone -b 0.3.1 git://github.com/armet/python-armet.git - ``` - - To speed up the clone if you're just cloning the project for use and - don't intend to contribute back upstream, you can add the `--depth ` - option to do a shallow clone, in which it will only fetch the - exact version instead of the entire history. - - ```sh - git clone -b 0.3.1 --depth 1 git://github.com/armet/python-armet.git - ``` - 2. Change into the **armet** root directory. ```sh @@ -104,57 +43,6 @@ gives application developers the full power and flexibility of SQL. pip install . ``` - Additional *extra* requirements may be specified in brackets following - the `.`. - - ```sh - # Install armet as well as the additional dependencies to use - # the unit test suite. - pip install ".[test]" - ``` - -## Contributing - -### Setting up your environment -1. Follow steps 1 and 2 of the [manual installation instructions][]. - -[manual installation instructions]: #manual - -2. Initialize a virtual environment to develop in. - This is done so as to ensure every contributor is working with - close-to-identicial versions of packages. - - ```sh - mkvirtualenv armet - ``` - - The `mkvirtualenv` command is available from `virtualenvwrapper` which - can be installed as follows: - - ```sh - sudo pip install virtualenvwrapper - ``` - -3. Install **armet** in development mode with testing enabled. - This will download all dependencies required for running the unit tests. - - ```sh - pip install -e ".[test]" - ``` - -### Running the test suite -1. [Set up your environment](#setting-up-your-environment). - -2. Run the unit tests. - - ```sh - py.test - ``` - -**Armet** is maintained with all tests passing at all times. If you find -a failure, please [report it](https://github.com/armet/python-armet/issues/new) -along with the version. - ## License Unless otherwise noted, all files contained within this project are liensed under the MIT opensource license. See the included file LICENSE or visit diff --git a/armet/__init__.py b/armet/__init__.py deleted file mode 100644 index e2003db..0000000 --- a/armet/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ._version import __version__, __version_info__ # noqa -from .decorators import route, resource, asynchronous -from .helpers import use -from .relationship import Relationship - -__all__ = [ - 'route', - 'resource', - 'asynchronous', - 'use', - 'Relationship' -] diff --git a/armet/_version.py b/armet/_version.py deleted file mode 100644 index b9bb55d..0000000 --- a/armet/_version.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import, division - -__version_info__ = (0, 4, 43) -__version__ = '.'.join(map(str, __version_info__)) diff --git a/armet/attributes/__init__.py b/armet/attributes/__init__.py deleted file mode 100644 index b52bea7..0000000 --- a/armet/attributes/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -from .primitive import BooleanAttribute, TextAttribute, IntegerAttribute -from .temporal import TimeAttribute, DateAttribute, DateTimeAttribute -from .uuid import UUIDAttribute -from .timezone import TimezoneAttribute -from .decimal import DecimalAttribute - - -__all__ = [ - 'Attribute', - 'BooleanAttribute', - 'TextAttribute', - 'IntegerAttribute', - 'TimeAttribute', - 'DateAttribute', - 'DateTimeAttribute', - 'UUIDAttribute', - 'TimezoneAttribute', - 'DecimalAttribute' -] diff --git a/armet/attributes/attribute.py b/armet/attributes/attribute.py deleted file mode 100644 index 6d159f4..0000000 --- a/armet/attributes/attribute.py +++ /dev/null @@ -1,264 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.utils import compose -from collections import defaultdict -from functools import partial - - -class Attribute(object): - """Generic attribute. - - Use this to indicate that the type of the data is unmanaged by armet. - This is also an extension point for further attributes. - """ - - #! Python type expected to be marshalled through this attribute. - type = None - - def __init__(self, path=None, **kwargs): - - #! Attribute may be accessed directly. - #! Note that setting this to false will still show the attribute in - #! the body. Set include to false to hide it from the body. - self.read = kwargs.get('read', True) - - #! Attribute may be modified through any operation. - self.write = kwargs.get('write', True) - - #! Attribute is included in the response body. - self.include = kwargs.get('include', True) - - #! Attribute can accept a null value. - self.null = kwargs.get('null', True) - - #! Attribute must be present in the body. - self.required = kwargs.get('required', False) - - #! Attribute is to be treated as a collection. - self.collection = kwargs.get('collection', False) - - #! Override python name of the attribute. - self.name = kwargs.get('name') - - #! Flag to turn the set operation into a no-op. - # HACK: This is in-place in order to get around a bug I've encountered - # until relationships are implemented. - self._set = kwargs.get('_set', True) - - #! The path reference of where to find this attribute on an - #! item (eg. 'name' references the name key if the read method returns - #! a dictionary.) - #! - #! The path may be dot-separated to indicate simple traversal. - #! Eg. user.name could be obj['user'].name - self.path = path - if self.path: - # Explode the path into segments to iterate over in operations. - self._segments = defaultdict(lambda: self.path.split('.')) - - # Attributes use a lazy optimization process for attribute lookup - # on the underlying data model. When an attribute is accessed - # it builds and caches the operation action. - self._getters = defaultdict(list) - - # Attribute cache that is keyed on the type of the target object. - this = self - - class GetResolver(dict): - - def __missing__(self, key): - return partial(this._resolve_get, self, key.__class__) - - self._get = GetResolver() - - def _resolve_get(self, resolver, key, target): - if self.path is None: - # There is no path defined on this resource. - # We can do no magic to get the value. - resolver[key] = lambda *a: None - return None - - # Iterate and resolve each constructed getter. - for func in self._getters[key]: - if target is None: - # No more getters can be resolved. - return None - - # Resolve this getter and continue iteration. - target = func(target) - - # Are their segments remaining to make a getter for? - while self._segments[key]: - if target is None: - # No more getters can be made. - return None - - # Fetch the next path segment. - segment = self._segments[key].pop(0) - - # Make and append the corresponding getter. - func = self._make_getter(segment, key) - self._getters[key].append(func) - - # Resolve the getter. - target = func(target) - - # Have we finished resolving the getters? - if not self._segments[key]: - # Compose a replacement get function that just iterates over - # the getters. - resolver[key] = compose(*self._getters[key]) - - # Return our resolved value. - return target - - def get(self, target): - """Retrieve the value of this attribute from the passed object. - """ - - # Attempt to resolve an accessor for the specific target. Creates - # the accessor if not available. - return self._get[target](target) - - def set(self, target, value): - """Set the value of this attribute for the passed object. - """ - - if not self._set: - return - - if self.path is None: - # There is no path defined on this resource. - # We can do no magic to set the value. - self.set = lambda *a: None - return None - - if self._segments[target.__class__]: - # Attempt to resolve access to this attribute. - self.get(target) - - if self._segments[target.__class__]: - # Attribute is not fully resolved; an interim segment is null. - return - - # Resolve access to the parent object. - # For a single-segment path this will effectively be a no-op. - parent_getter = compose(*self._getters[target.__class__][:-1]) - target = parent_getter(target) - - # Make the setter. - func = self._make_setter(self.path.split('.')[-1], target.__class__) - - # Apply the setter now. - func(target, value) - - # Replace this function with the constructed setter. - def setter(target, value): - func(parent_getter(target), value) - - self.set = setter - - def prepare(self, value): - """Prepare the value for serialization and presentation to the client. - """ - - # By default, do nothing to the value. - return value - - def clean(self, value): - """Cleans the value from deserialization into consumption by python. - """ - - # By default, do nothing to the value. - return value - - def try_clean(self, value): - try: - # TODO: Remove usage of this in query builder. - return self.clean(value) - - except (ValueError, AssertionError): - return None - - def clone(self): - """Construct an identical attribute. - - Used by the resource metaclass to ensure all attributes are unique - instances. This is done so that when getters and setters are resolved - the caches don't clobber base classes (for inherited attributes). - """ - - return self.__class__(**self.__dict__) - - def _make_getter(self, segment, class_): - - # Attempt to resolve properties and simple functions by - # accessing the class attribute directly. - obj = getattr(class_, segment, None) - if obj is not None: - if hasattr(obj, '__call__'): - return obj.__call__ - - if hasattr(obj, '__get__'): - return lambda target, x=obj.__get__: x(target, class_) - - # Check for much better hidden descriptor. - obj = class_.__dict__.get(segment) - if obj is not None and hasattr(obj, '__get__'): - return lambda target, x=obj.__get__: x(target, class_) - - # Check for item access (for a dictionary). - if hasattr(class_, '__getitem__'): - def getter(target): - try: - return target[segment] - - except KeyError: - return None - - return getter - - # Check for attribute access. - if hasattr(class_, '__dict__'): - return lambda target: target.__dict__.get(segment) - - raise RuntimeError( - 'unable to resolve attribute access for %r on %r' % ( - segment, class_)) - - def _make_setter(self, segment, class_): - - # Attempt to resolve a data descriptor. - obj = getattr(class_, segment, None) - if obj is not None: - if hasattr(obj, '__set__'): - def setter(target, value, x=obj.__set__): - x(target, value) - - return setter - - # Check for much better hidden descriptor. - obj = class_.__dict__.get(segment) - if obj is not None and hasattr(obj, '__set__'): - def setter(target, value, x=obj.__set__): - x(target, value) - - return setter - - # Check for item access (for a dictionary). - if hasattr(class_, '__getitem__'): - def setter(target, value): - target[segment] = value - - return setter - - # Check for attribute access. - if hasattr(class_, '__dict__'): - def setter(target, value): - target.__dict__[segment] = value - - return setter - - raise RuntimeError( - 'unable to resolve attribute setter for %r on %r' % ( - segment, class_)) diff --git a/armet/attributes/decimal.py b/armet/attributes/decimal.py deleted file mode 100644 index 2897dc0..0000000 --- a/armet/attributes/decimal.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .attribute import Attribute -import decimal - - -class DecimalAttribute(Attribute): - - type = decimal.Decimal - - def prepare(self, value): - if value is None: - return None - - return six.text_type(float(value)) - - def clean(self, value): - if isinstance(value, decimal.Decimal): - # Value is already an integer. - return value - - if isinstance(value, six.string_types): - # Strip the string of whitespace - value = value.strip() - - if not value: - # Value is nothing; return nothing. - return None - - try: - # Attempt to coerce whatever we have as an int. - return decimal.Decimal(value) - - except (ValueError, decimal.InvalidOperation): - # Failed to do so. - raise ValueError('Not a valid decimal value.') diff --git a/armet/attributes/primitive.py b/armet/attributes/primitive.py deleted file mode 100644 index 14a008c..0000000 --- a/armet/attributes/primitive.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .attribute import Attribute - - -class BooleanAttribute(Attribute): - - type = bool - - #! Textual values accepted for `True`. - TRUE = ( - 'true', - 't', - 'yes', - 'y', - 'on', - '1' - ) - - #! Textual values accepted for `False`. - FALSE = ( - 'false', - 'f', - 'no', - 'n', - 'off', - '0' - ) - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - if value is True or value is False: - # Value is a python boolean; just return it. - return value - - if value.strip().lower() in self.TRUE: - # Some sort of truthy value. - return True - - if value.strip().lower() in self.FALSE: - # Some sort of falsy value. - return False - - # Neither true or false matches; return a boolifyed version of - # whatever we have. - return bool(value) - - -class TextAttribute(Attribute): - - type = six.text_type - - def clean(self, value): - return six.text_type(value) if value is not None else None - - -class IntegerAttribute(Attribute): - - type = int - - def clean(self, value): - if isinstance(value, six.integer_types): - # Value is already an integer. - return value - - if isinstance(value, six.string_types): - # Strip the string of whitespace - value = value.strip() - - if not value: - # Value is nothing; return nothing. - return None - - try: - # Attempt to coerce whatever we have as an int. - return int(value) - - except ValueError: - # Failed to do so. - raise ValueError('Not a valid integral value.') diff --git a/armet/attributes/temporal.py b/armet/attributes/temporal.py deleted file mode 100644 index 23e5433..0000000 --- a/armet/attributes/temporal.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -import datetime -from armet import exceptions - - -try: - # Attempt to import and make use of date/time libraries. - # TODO: Make note of this optional dep somewhere. - from dateutil.parser import parse as parse_datetime - from time import mktime - -except ImportError: - # No support for date/times. - parse_datetime = None - - -class _TemporalAttribute(Attribute): - - """Represents a temporal attribute, such as a date or time. - """ - - def __init__(self, *args, **kwargs): - # Ensure we have support. - if parse_datetime is None: - raise exceptions.ImproperlyConfigured( - 'Use of temporal attributes requires at ' - 'least python-dateutil (and optionally parsedatetime).') - - # Continue on. - super(_TemporalAttribute, self).__init__(*args, **kwargs) - - def prepare(self, value): - if not value: - return None - - # Serialize as ISO format. - return value.isoformat() - - def clean(self, value): - if not value: - # Value is nothing; return it. - return value - - try: - # Attempt to use the dateutil library to parse. - return parse_datetime(value, fuzzy=False) - - except (TypeError, ValueError, AttributeError): - # Not a strictly formatted date; return nothing. - pass - - try: - # Attempt to magic a date out of it. - # TODO: List this somewhere as an optional dep. - import parsedatetime as pdt - # from parsedatetime import parsedatetime as pdt - c = pdt.Constants() - c.BirthdayEpoch = 80 # TODO: Figure out what this is. - p = pdt.Calendar(c) - result = p.parse(value) - if result[1] != 0: - return datetime.datetime.fromtimestamp(mktime(result[0])) - - except (NameError, ImportError, TypeError): - # No magical date/time support. - pass - - # Couldn't figure out what we're dealing with. - raise ValueError('Invalid date/time or in an invalid format.') - - -class DateAttribute(_TemporalAttribute, Attribute): - - type = datetime.date - - def clean(self, value): - value = super(DateAttribute, self).clean(value) - if value: - # Constrain to just the date part. - return value.date() - - -class TimeAttribute(_TemporalAttribute, Attribute): - - type = datetime.time - - def clean(self, value): - value = super(TimeAttribute, self).clean(value) - if value: - # Constrain to just the date part. - return value.time() - - -class DateTimeAttribute(_TemporalAttribute, Attribute): - - type = datetime.datetime diff --git a/armet/attributes/timezone.py b/armet/attributes/timezone.py deleted file mode 100644 index 016ed39..0000000 --- a/armet/attributes/timezone.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import exceptions -from .attribute import Attribute - - -try: - # TODO: List this somewhere as an optional dep. - import pytz - -except ImportError: - # No timezone support. - pytz = None - - -class TimezoneAttribute(Attribute): - - if pytz is not None: - type = pytz.tzfile.DstTzInfo - - def __init__(self, *args, **kwargs): - # Ensure we have support. - if pytz is None: - raise exceptions.ImproperlyConfigured( - 'Use of the timezone attribute requires pytz') - - # Continue on. - super(TimezoneAttribute, self).__init__(*args, **kwargs) - - def prepare(self, value): - return value.zone - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - try: - # Attempt to coerce the timezone. - return pytz.timezone(value) - - except pytz.UnknownTimeZoneError: - raise ValueError('Unknown time zone.') diff --git a/armet/attributes/uuid.py b/armet/attributes/uuid.py deleted file mode 100644 index 88b88ee..0000000 --- a/armet/attributes/uuid.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -from armet import exceptions -import uuid - - -try: - import shortuuid - -except ImportError: - shortuuid = None - - -class UUIDAttribute(Attribute): - - type = uuid.UUID - - def __init__(self, *args, **kwargs): - super(UUIDAttribute, self).__init__(*args, **kwargs) - - #! Encode and decode the UUID using 'shortuuid' (only if available). - self.short = kwargs.get('short') - if self.short is None or self.short: - if shortuuid is None: - if self.short is None: - self.short = False - return - - else: - raise exceptions.ImproperlyConfigured( - "Use of 'short' UUID attributes requires the " - "'shortuuid' package.") - - # Default to using short UUIDs if we have the package. - self.short = True - - def prepare(self, value): - if value is None: - return None - - if self.short: - # Serialize as a 22-digit hex representation. - return shortuuid.encode(value) - - # Serialize as the 32-digit hex representation. - return value.hex - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - try: - try: - if self.short: - # Attempt to coerce the short UUID. - return shortuuid.decode(value) - - except ValueError: - pass - - # Attempt to coerce the UUID. - return uuid.UUID(value) - - except ValueError: - raise ValueError( - 'UUID must be of the form: ' - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') diff --git a/armet/authentication.py b/armet/authentication.py deleted file mode 100644 index 44847a4..0000000 --- a/armet/authentication.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Describes the authentication protocols and generalizations used to -authenticate access to a resource endpoint. -""" -from __future__ import absolute_import, unicode_literals, division -from armet import http -import base64 - - -class Authentication(object): - - #! Whether returning `None` from `authenticate` indicates - #! unauthenticated; or, simply no authenticated user. - allow_anonymous = True - - def __init__(self, **kwargs): - """Initialize authentication protocol; establish parameters. - """ - - # Allow overriding class attributes. - self.__dict__.update(kwargs) - - def authenticate(self, request): - """Gets the a user if they are authenticated; else None. - - @retval False Unable to authenticate. - @retval None Able to authenticate but failed. - @retval User object representing the current user. - """ - return None - - def unauthenticated(self): - """ - Callback that is invoked when after a user is determined to - be unauthenticated. - """ - raise http.exceptions.Forbidden() - - -class HeaderAuthentication(Authentication): - - def can_authenticate(self, request, method): - # Determine if we can authenticate. - return False - - def get_credentials(self, text): - # Decode credentials. - return text, - - def get_user(self, request, method, *args): - """ - Callback that is invoked when a user is attempting to be - authenticated with a set of credentials. - """ - return None - - def authenticate(self, request): - # Retrieve the authorization header. - header = request.headers.get('Authorization') - - try: - # Split the authorization header into method and credentials. - method, text = (header or '').split(' ', 1) - - except ValueError: - # Strange format in the header. - return False - - if not self.can_authenticate(request, method): - # Not the right kind of authentication. - return False - - # Retreive and return the user object. - return self.get_user(request, method, *self.get_credentials(text)) - - -class BasicAuthentication(HeaderAuthentication): - - allow_anonymous = False - - def can_authenticate(self, request, method): - # Determine if we can authenticate. - return method.lower() == 'basic' - - def get_credentials(self, text): - try: - # Decode credentials. - text = base64.b64decode(text.encode('utf8')).decode('utf8') - return text.split(':', 1) - - except base64.binascii.Error: - # Decoding error. - return None, None diff --git a/armet/authorization.py b/armet/authorization.py deleted file mode 100644 index 2b8b9e9..0000000 --- a/armet/authorization.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Describes the authorization protocols and generalizations used to -authorize access to a resource endpoint. -""" -from __future__ import absolute_import, unicode_literals, division -from armet.exceptions import ImproperlyConfigured -from armet import http - - -class Authorization(object): - """ - Establishes the protocol for authorization; by default, all users are - authorized. - """ - - def is_accessible(self, user, method, resource): - """ - Determines the accessibility to a resource endpoint for a particular - method. An inaccessible resource is indistinguishable from a - non-existant resource. - - @param[in] user - The user in question that is being checked. - - @param[in] method - The method in question that is being performed (eg. 'GET'). - - @param[in] resource - The resource instance that is being authorized. - - @returns - Returns true if the user can access the resource for - the passed operation; otherwise, false. - """ - return True - - def inaccessible(self): - """Informs the client that the resource is inaccessible.""" - raise http.exceptions.Forbidden() - - def is_authorized(self, user, operation, resource, item): - """Determines authroization to a specific resource object. - - @param[in] user - The user in question that is being checked. - - @param[in] operation - The operation in question that is being performed (eg. 'read'). - - @param[in] resource - The resource instance that is being authorized. - - @param[in] item - The specific instance of an object returned by a `read` from - the `resource`. - - @returns - Returns true to indicate authorization or false to indicate - otherwise. - """ - return True - - def unauthorized(self): - """Informs the client that it is not authrozied for the resource.""" - raise http.exceptions.Forbidden() - - def filter(self, user, operation, resource, iterable): - """ - Filters an iterable to contain only the items for which the user - is authorized to perform the operation on. - - @param[in] user - The user in question that is being checked. - - @param[in] operation - The operation in question that is being performed (eg. 'read'). - - @param[in] resource - The resource instance that is being authorized. - - @param[in] iterable - The iterable of objects to be checked. This method is called - from the model connector so the actual value of this parameter - depends on the model connector (eg. it may be a django queryset). - - @returns - Returns an iterable containing the remaining objects. - """ - return iterable - - -class ManagedAuthorization(Authorization): - """Extends the authorization protocol to apply to managed resources. - - Managed resources only check accessibility and authorization based on - the operation (eg. 'read') and not the method (eg. 'GET'). - """ - - def inaccessible(self): - """Informs the client that the resource is inaccessible.""" - raise http.exceptions.NotFound() diff --git a/armet/connectors/__init__.py b/armet/connectors/__init__.py deleted file mode 100644 index 87f31b8..0000000 --- a/armet/connectors/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - -#! List of available HTTP/1.1 connectors. -http = ('bottle', 'flask', 'django',) - -#! List of available ORM connectors. -model = ('django', 'sqlalchemy') diff --git a/armet/connectors/bottle/__init__.py b/armet/connectors/bottle/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/bottle/http.py b/armet/connectors/bottle/http.py deleted file mode 100644 index 684f7c2..0000000 --- a/armet/connectors/bottle/http.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -import bottle - - -class RequestHeaders(http.request.Headers): - - def __getitem__(self, name): - return bottle.request.headers[name] - - def __iter__(self): - return iter(bottle.request.headers) - - def __len__(self): - return len(bottle.request.headers) - - def __contains__(self, name): - return name in bottle.request.headers - - -class Request(http.Request): - - def __init__(self, *args, **kwargs): - # Initialize the request headers. - self.headers = RequestHeaders() - - # Set the method of the request. - request = bottle.request - kwargs.update(method=request.method) - - # Elide the thread-safe request copy and the global bottle.request. - self._handle = request.copy() if kwargs['asynchronous'] else request - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._handle.body.read() - - @property - def protocol(self): - return self._handle.urlparts.scheme.upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - return self._handle.query_string - - @property - def uri(self): - return self._handle.url - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __setitem__(self, name, value): - self._response.require_open() - self._handle.headers[self.normalize(name)] = value - - def __getitem__(self, name): - return self._handle.headers[name] - - def __contains__(self, name): - return name in self._handle.headers - - def __delitem__(self, name): - self._response.require_open() - del self._handle.headers[name] - - def __len__(self): - return len(self._handle.headers) - - def __iter__(self): - return iter(self._handle.headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Elide the thread-safe response copy and the global bottle.response. - self._handle = bottle.response - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have a thread-safe response handle as well as an - # asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - - self._handle = self._handle.copy() - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status = value - - @http.Response.body.setter - def body(self, value): - if value: - if not self.streaming: - # Point the response handle to our constructed response. - bottle.response.status = self.status - bottle.response.headers.update(self.headers) - - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/bottle/resources.py b/armet/connectors/bottle/resources.py deleted file mode 100644 index 6552d81..0000000 --- a/armet/connectors/bottle/resources.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import bottle -from . import http - - -class Resource(object): - - @classmethod - def view(cls, *args, **kwargs): - # Construct request and response wrappers. - async = cls.meta.asynchronous - request = http.Request(path=kwargs.get('path', ''), asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return a streamer. - return cls.stream(response, response) - - # Pass control off to the resource handler. - return super(Resource, cls).view(request, response) - - @classmethod - def mount(cls, url='/', application=None): - if application is None: - # If no explicit application is passed; use - # the current default application. - application = bottle.app[-1] - - # Generate a name to use to mount this resource. - name = '{}:{}:{}'.format('armet', cls.__module__, cls.meta.name) - - # Apply the routing rules and add the URL route. - rule = '{}{}'.format(url, cls.meta.name) - application.route(rule, 'ANY', cls.view, name) diff --git a/armet/connectors/django/__init__.py b/armet/connectors/django/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/django/http.py b/armet/connectors/django/http.py deleted file mode 100644 index be5bdb2..0000000 --- a/armet/connectors/django/http.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.http import HttpResponse -from armet import http -from io import BytesIO -import re - - -# For almost all headers, django prefixes the header with `HTTP_`. This is a -# list of headers that are an exception to that rule. -SPECIAL_HEADERS = ('CONTENT_TYPE', 'CONTENT_LENGTH') - - -def _normalize(name): - name = re.sub(r'^HTTP-', '', name.replace('_', '-')) - name = http.request.Headers.normalize(name) - return name - - -def _denormalize(name): - name = name.replace('-', '_').upper() - return name if name in SPECIAL_HEADERS else 'HTTP_' + name - - -class RequestHeaders(http.request.Headers): - - def __init__(self, handle): - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(RequestHeaders, self).__init__() - - @staticmethod - def normalize(name): - # Proxy for internal usage of this. - return _normalize(name) - - def __getitem__(self, name): - return self._handle.META[_denormalize(name)] - - def __iter__(self): - for name in self._handle.META: - if name.startswith('HTTP_') or name in SPECIAL_HEADERS: - yield self.normalize(name) - - def __len__(self): - return sum(1 for x in self) - - def __contains__(self, name): - return _denormalize(name) in self._handle.META - - -class Request(http.Request): - - def __init__(self, request, *args, **kwargs): - # Store the request handle. - self._handle = request - - # Initialize the request headers. - self.headers = RequestHeaders(request) - - # Store a stream of the request. - self._stream = BytesIO(request.body) - - # Set the method of the request. - kwargs.update(method=self._handle.method) - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._stream.read() - - @property - def protocol(self): - return self._handle.META['SERVER_PROTOCOL'].split('/')[0].upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - return self._handle.META.get('QUERY_STRING', '') - - @property - def uri(self): - return self._handle.build_absolute_uri() - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle[name] - - def __setitem__(self, name, value): - self._response.require_open() - self._handle[self.normalize(name)] = value - - def __delitem__(self, name): - self._response.require_open() - del self._handle[name] - - def __contains__(self, name): - return self._handle.has_header(name) - - def __iter__(self): - for name, _ in self._handle.items(): - yield name - - def __len__(self): - return len(self._handle._headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Construct and store a new response object. - self._handle = HttpResponse() - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have an asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status_code = value - - @http.Response.body.setter - def body(self, value): - if value: - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/django/resources.py b/armet/connectors/django/resources.py deleted file mode 100644 index 2cebc1e..0000000 --- a/armet/connectors/django/resources.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import operator -import six -from six.moves import map, reduce -from django.conf import urls -from django.db.models import Q -from django.views.decorators import csrf -from armet import utils -from . import http -from armet.query import parser, Query, QuerySegment, constants - - -class Resource(object): - - @classmethod - @csrf.csrf_exempt - def view(cls, django_request, *args, **kwargs): - # Construct request and response wrappers. - async = cls.meta.asynchronous - path = kwargs.get('path') or '' - request = http.Request(django_request, path=path, asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return the generator response. - response._handle.content = cls.stream(response, response) - return response._handle - - # Pass control off to the resource handler. - result = super(Resource, cls).view(request, response) - - # Configure the response and return it. - response._handle.content = result - return response._handle - - @utils.classproperty - def urls(cls): - """Builds the URL configuration for this resource.""" - return urls.patterns('', urls.url( - r'^{}(?:$|(?P[/:(.].*))'.format(cls.meta.name), - cls.view, - name='armet-api-{}'.format(cls.meta.name), - kwargs={'resource': cls.meta.name})) - - -# Build an operator map to use for django. -OPERATOR_MAP = { - constants.OPERATOR_EQUAL: '__exact', - constants.OPERATOR_IEQUAL: '__iexact', - constants.OPERATOR_LT: '__lt', - constants.OPERATOR_GT: '__gt', - constants.OPERATOR_LTE: '__lte', - constants.OPERATOR_GTE: '__gte', -} - -# import ipdb; ipdb.set_trace() -# Rewire the map. -OPERATOR_MAP = dict( - (constants.OPERATOR_MAP[k], v) for k, v in OPERATOR_MAP.items() -) - - -def segment_query(seg, attributes): - - # Get the attribute in question. - attribute = attributes[seg.path[0]] - - # Replace the initial path segment with the expanded - # attribute path. - seg.path[0:1] = attribute.path.split('.') - - # Boolean's should use `exact` rather than `iexact`. - if attribute.type is bool: - op = '__exact' - else: - op = OPERATOR_MAP[seg.operator] - - # Build the path from the segment. - path = '__'.join(seg.path) + op - - # Construct a Q-object from the segment. - return reduce(operator.or_, - map(lambda x: Q((path, x)), - map(attribute.try_clean, seg.values))) - - -def noop_query(*args): - return Q() - - -def unary_query(query, *args): - return query.operation(build_clause(query.operand, *args)) - - -def binary_query(query, *args): - return query.operation( - build_clause(query.left, *args), - build_clause(query.right, *args)) - - -CLAUSE_MAP = { - parser.NoopQuerySegment: noop_query, - parser.BinarySegmentCombinator: binary_query, - parser.UnarySegmentCombinator: unary_query, - parser.QuerySegment: segment_query, -} - - -def build_clause(query, attributes): - class_ = type(query) if not isinstance(query, type) else query - fn = CLAUSE_MAP.get(class_) - if fn is not None: - return fn(query, attributes) - elif issubclass(class_, Query): - return build_clause(query.parsed, attributes) - else: - raise ValueError('Unable to translate query node %s' % str(query)) - -# def build_clause(query, attributes): -# # Iterate through each query segment. -# clause = None -# last = None -# for seg in query.segments: -# # Get the attribute in question. -# attribute = attributes[seg.path[0]] - -# # Replace the initial path segment with the expanded -# # attribute path. -# seg.path[0:1] = attribute.path.split('.') - -# # Boolean's should use `exact` rather than `iexact`. -# if attribute.type is bool: -# op = '__exact' -# else: -# op = OPERATOR_MAP[seg.operator] - -# # Build the path from the segment. -# path = '__'.join(seg.path) + op - -# # Construct a Q-object from the segment. -# q = reduce(operator.or_, -# map(lambda x: Q((path, x)), -# map(attribute.try_clean, seg.values))) - -# # Combine the segment with the last. -# clause = last.combinator(clause, q) if last is not None else q -# last = seg - -# # Return the constructed clause. -# return clause - - -class ModelResource(object): - - def filter(self, clause, queryset): - # Filter the queryset by the passed clause. - return queryset.filter(clause).distinct() - - def count(self, queryset): - # Return the count of the queryset. - return len(queryset) - - def read(self): - # Initialize the queryset to the model manager. - queryset = self.meta.model.objects - - query = None - if self.slug is not None: - # This is an item-access (eg. GET //:slug); ignore the - # query string and generate a query-object based on the slug. - query = Query( - original=None, - parsed=QuerySegment( - path=self.meta.slug.path.split('.'), - operator=constants.OPERATOR_MAP[constants.OPERATOR_EQUAL], - values=[self.slug] - ) - ) - - elif self.request.query: - # This is a list-access; use the query string and construct - # a query object from it. - query = parser.parse(self.request.query) - - # Determine if we need to filter the queryset in some way; and if so, - # filter it. - if query is not None: - clause = build_clause(query, self.attributes) - queryset = self.filter(clause, queryset) - - # Filter the queryset by asserting authorization. - queryset = self.meta.authorization.filter( - self.request.user, 'read', self, queryset) - - if self.slug is not None: - # Attempt to return just the single result we should have. - result = queryset.all()[:1] - return result[0] if result else None - - # Return the entire queryset. - return queryset.all() - - def create(self, data): - # Instantiate a new target. - target = self.meta.model() - - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - value = data.get(name) - if value is not None: - attribute.set(target, value) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'create', self, target): - authz.unauthorized() - - # Save the target. - target.save() - - # Return the target. - return target - - def update(self, target, data): - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - attribute.set(target, data.get(name)) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - # Save the target. - target.save() - - def destroy(self): - # Grab the existing target. - target = self.read() - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'destroy', self, target): - authz.unauthorized() - - # Destroy the target. - target.delete() diff --git a/armet/connectors/flask/__init__.py b/armet/connectors/flask/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/flask/http.py b/armet/connectors/flask/http.py deleted file mode 100644 index dda85fd..0000000 --- a/armet/connectors/flask/http.py +++ /dev/null @@ -1,158 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from armet import http -import flask -from flask.globals import current_app -from werkzeug.wsgi import get_current_url - - -class RequestHeaders(http.request.Headers): - - def __init__(self, handle): - #! Reference to the underlying request handle. - self._handle = handle - - # Continue the initialization. - super(RequestHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle.headers[name] - - def __iter__(self): - return (key for key, _ in self._handle.headers) - - def __len__(self): - return len(self._handle.headers) - - def __contains__(self, name): - return name in self._handle.headers - - -class Request(http.Request): - - def __init__(self, *args, **kwargs): - # Elide the thread-safe request copy and the global bottle.request. - request = flask.request - async = kwargs['asynchronous'] - self._handle = request if not async else flask.Request(request.environ) - - # Initialize the request headers. - self.headers = RequestHeaders(self._handle) - - # Set the method of the request. - kwargs.update(method=request.method) - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._handle.stream.read() - - @property - def protocol(self): - return self._handle.scheme.upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - if isinstance(self._handle.query_string, six.binary_type): - return self._handle.query_string.decode('utf8') - - return self._handle.query_string - - @property - def uri(self): - return get_current_url(self._handle.environ) - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle.headers[name] - - def __setitem__(self, name, value): - self._response.require_open() - self._handle.headers[self.normalize(name)] = value - - def __contains__(self, name): - return name in self._handle.headers - - def __delitem__(self, name): - self._response.require_open() - del self._handle.headers[name] - - def __iter__(self): - return (key for key, _ in self._handle.headers) - - def __len__(self): - return len(self._handle.headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Construct and store a new response object. - self._handle = current_app.response_class() - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have an asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status_code = value - - @http.Response.body.setter - def body(self, value): - if value: - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/flask/resources.py b/armet/connectors/flask/resources.py deleted file mode 100644 index 8731cf7..0000000 --- a/armet/connectors/flask/resources.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import collections -import flask -from werkzeug.routing import BaseConverter -from . import http - - -class RegexConverter(BaseConverter): - """Regular expression URL converter for werkzeug / flask. - """ - - def __init__(self, url_map, pattern=r'(.*)'): - super(RegexConverter, self).__init__(url_map) - self.regex = pattern - - -class Resource(object): - - @classmethod - def view(cls, *args, **kwargs): - # Initiate the base view request cycle. - path = kwargs.get('path', '') - if flask.request.path.endswith('/'): - # The trailing slash is stripped for fun by werkzeug. - path += '/' - - # Construct request and response wrappers. - async = cls.meta.asynchronous - request = http.Request(path=path, asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return the generator response. - response._handle.response = cls.stream(response, response) - return response._handle - - # Pass control off to the resource handler. - result = super(Resource, cls).view(request, response) - - if isinstance(result, collections.Iterator): - # Construct and return the generator response. - response._handle.response = result - return response._handle - - # Configure the response if we received any data. - if result is not None: - response._handle.data = result - - # Return the response. - return response._handle - - @classmethod - def mount(cls, url, app=None): - # Generate a name to use to mount this resource. - name = 'armet' + cls.__module__ + cls.meta.name + url - - # If application is not provided; make use of the app context. - if app is None: - app = flask.current_app - - # Prepare the flask application to accept armet resources. - # The strict slashes setting must be False for our routes. - strict_slashes = app.url_map.strict_slashes = False - - # Ensure that there is a compliant regex converter available. - converter = app.url_map.converters['default'] - app.url_map.converters['default'] = RegexConverter - - # Mount this resource. - pattern = '{}{}'.format(url, cls.meta.name) - rule = app.url_rule_class(pattern, endpoint=name) - app.url_map.add(rule) - app.view_functions[name] = cls.view - - # Restore the flask environment. - app.url_map.strict_slashes = strict_slashes - app.url_map.converters['default'] = converter - - def _request_read(self, path): - # Save the current request object. - _req = flask.request._get_current_object() - - # Build a new environ object. - env = dict(flask.request.environ) - env['PATH_INFO'] = path - env['REQUEST_METHOD'] = 'GET' - if 'HTTP_X_HTTP_METHOD_OVERRIDE' in env: - del env['HTTP_X_HTTP_METHOD_OVERRIDE'] - - # Build and insert a new request object. - req = flask.Request(env) - flask._request_ctx_stack.top.request = req - - # Bind the url-map and pull out the - urls = flask.current_app.url_map.bind_to_environ(env) - endpoint, args = urls.match() - - # Pull out the resource class. - cls = flask.current_app.view_functions[endpoint].__self__ - - # Construct a request wrapper. - request = http.Request(path=args['path'], asynchronous=False) - - # Construct a resource object. - resource = cls(request=request, response=None) - - # Perform the `read` request. - resource.require_authentication(resource.request) - result = resource.read() - - # Restore the request object. - flask._request_ctx_stack.top.request = _req - - # Return what we read - return result diff --git a/armet/connectors/sqlalchemy/__init__.py b/armet/connectors/sqlalchemy/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/sqlalchemy/authorization.py b/armet/connectors/sqlalchemy/authorization.py deleted file mode 100644 index e278a0f..0000000 --- a/armet/connectors/sqlalchemy/authorization.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.authorization import ManagedAuthorization -from armet.exceptions import ImproperlyConfigured - - -try: - import shield - -except ImportError: - shield = None - - -class ShieldAuthorization(ManagedAuthorization): - """Implements managed authorization using the shield permissions framework. - """ - - def __init__(self, permissions=None): - # Ensure we have access to the shield framework. - if shield is None: - raise ImproperlyConfigured( - "'shield' is required to use 'ShieldAuthorization'") - - #! Permissions to check for each operation. - self.permissions = permissions - if self.permissions is None: - self.permissions = { - 'read': ('read',), - 'create': ('create',), - 'update': ('update',), - 'destroy': ('destroy',) - } - - def is_authorized(self, user, operation, resource, item): - return shield.has( - *self.permissions[operation], - bearer=user, - target=item) - - def filter(self, user, operation, resource, iterable): - query = shield.filter( - *self.permissions[operation], - bearer=user, - target=resource.meta.model, - query=iterable) - - return query.distinct() diff --git a/armet/connectors/sqlalchemy/resources.py b/armet/connectors/sqlalchemy/resources.py deleted file mode 100644 index 6872cc0..0000000 --- a/armet/connectors/sqlalchemy/resources.py +++ /dev/null @@ -1,430 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import operator -from functools import partial -from collections import Iterable -from copy import deepcopy -from six.moves import map, reduce -from armet.exceptions import ImproperlyConfigured -from armet.query import parser, Query, QuerySegment, constants -from armet.http.exceptions import BadRequest -from sqlalchemy.exc import InvalidRequestError -import sqlalchemy as sa -import functools - - -class ModelResourceOptions(object): - - def __init__(self, meta, name, bases): - #! SQLAlchemy session used to perform operations on the models. - self.Session = meta.get('Session') - if not self.Session: - raise ImproperlyConfigured( - 'A session factory (via sessionmaker) is required by ' - 'the SQLAlchemy model connector.') - - -def ilike_helper(default): - """Helper function that performs an `ilike` query if a string value - is passed, otherwise the normal default operation.""" - @functools.wraps(default) - def wrapped(x, y): - # String values should use ILIKE queries. - if isinstance(y, six.string_types) and not isinstance(x.type, sa.Enum): - return x.ilike("%" + y + "%") - else: - return default(x, y) - return wrapped - - -# Build an operator map to use for sqlalchemy. -OPERATOR_MAP = { - constants.OPERATOR_EQUAL: operator.eq, - constants.OPERATOR_IEQUAL: ilike_helper(operator.eq), - constants.OPERATOR_LT: operator.lt, - constants.OPERATOR_GT: operator.gt, - constants.OPERATOR_LTE: operator.le, - constants.OPERATOR_GTE: operator.ge, - constants.OPERATOR_ICONTAINS: ilike_helper(operator.contains), -} - -# Rewire the map. -OPERATOR_MAP = dict( - (constants.OPERATOR_MAP[k], v) for k, v in OPERATOR_MAP.items() -) - - -def build_segment(model, segment, attr, clean): - # Get the associated column for the initial path. - path = segment.path.pop(0) - col = getattr(model, path) - - # Resolve the inner-most path segment. - if segment.path: - if col.impl.accepts_scalar_loader: - return col.has(build_segment( - col.property.mapper.class_, segment, attr, clean)) - - else: - try: - return col.any(build_segment( - col.property.mapper.class_, deepcopy(segment), - attr, clean)) - - except InvalidRequestError: - return col.has(build_segment( - col.property.mapper.class_, deepcopy(segment), - attr, clean)) - - # Determine the operator. - op = OPERATOR_MAP[segment.operator] - - # Apply the operator to the values and return the expression - qs = reduce(operator.or_, - map(partial(op, col), - map(lambda x: clean(attr.try_clean(x)), segment.values))) - - # Apply the negation. - if segment.negated: - qs = ~qs - - # Return our query object. - return qs - - -def segment_query(resource, segment, attributes, cleaners, model): - - attribute = attributes[segment.path[0]] - - # Modify the segment's to reflect what the attribute was declared for. - segment.path[0:1] = attribute.path.split('.') - - # Create a cleaner that can work here. - clean = partial(cleaners[attribute.name], resource) - - # Dispatch to the recursive segment building function - return build_segment(model, segment, attribute, clean) - - -def noop_query(*args): - return sa.sql.true() - - -def unary_query(resource, query, *args): - return query.operation(build_clause(resource, query.operand, *args)) - - -def binary_query(resource, query, *args): - return query.operation( - build_clause(resource, query.left, *args), - build_clause(resource, query.right, *args)) - - -CLAUSE_MAP = { - parser.NoopQuerySegment: noop_query, - parser.BinarySegmentCombinator: binary_query, - parser.UnarySegmentCombinator: unary_query, - parser.QuerySegment: segment_query, -} - - -def build_clause(resource, query, attributes, cleaners, model): - class_ = type(query) if not isinstance(query, type) else query - fn = CLAUSE_MAP.get(class_) - if fn is not None: - return fn(resource, query, attributes, cleaners, model) - elif issubclass(class_, Query): - return build_clause( - resource, - query.parsed, - attributes, - cleaners, - model) - else: - raise ValueError('Unable to translate query node %s' % str(query)) - - -class ModelResource(object): - """Specializes the RESTFul model resource protocol for SQLAlchemy. - - @note - This is not what you derive from to create resources. Import - ModelResource from `armet.resources` and derive from that. - """ - - def route(self, *args, **kwargs): - # Establish a session. - self.session = session = self.meta.Session() - - try: - # Continue on with the cycle. - result = super(ModelResource, self).route(*args, **kwargs) - - # Commit the session. - session.commit() - - # Return the result. - return result - - except: - # Something occurred; rollback the session. - session.rollback() - - # Re-raise the exception. - raise - - finally: - # Close the session. - session.close() - - def filter(self, clause, queryset): - # Filter the queryset by the passed clause. - return queryset.filter(clause).distinct() - - def count(self, queryset): - # Return the count of the queryset. - return queryset.count() - - def read(self): - # Initialize the query to the model. - queryset = self.session.query(self.meta.model) - - query = None - if self.slug is not None: - # This is an item-access (eg. GET //:slug); ignore the - # query string and generate a query-object based on the slug. - query = Query( - original=None, - parsed=QuerySegment( - path=self.meta.slug.path.split('.'), - operator=constants.OPERATOR_MAP[constants.OPERATOR_EQUAL], - values=[self.slug] - ) - ) - - elif self.request.query: - # This is a list-access; use the query string and construct - # a query object from it. - query = parser.parse(self.request.query) - - # Determine if we need to filter the queryset in some way; and if so, - # filter it. - clause = None - if query is not None: - clause = build_clause( - self, query, self.attributes, - self.cleaners, self.meta.model) - - queryset = self.filter(clause, queryset) - - if self.slug is None: - # Filter the queryset by asserting authorization. - queryset = self.meta.authorization.filter( - self.request.user, 'read', self, queryset) - - # Return the queryset. - return queryset - - else: - # Get the item in question. - item = queryset.first() - - # Sanity check to make sure some item was found. - if item is None: - return None - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'read', self, item): - authz.unauthorized() - - # We're good, return the item. - return item - - def create(self, data): - # Instantiate a new target. - target = self.meta.model() - - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - value = data.get(name) - if value is not None: - attribute.set(target, value) - - # Iterate through all write-able relations and set each one. - for name, relation in six.iteritems(self.relationships): - if relation.write: - # Set each one on the target. - value = data.get(name) - if value is not None: - # FIXME: Use some deferred thing that is not `setattr`. - setattr(target, relation.key, value) - - # Add the target to the session. - self.session.add(target) - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'create', self, target): - authz.unauthorized() - - # Return the target. - return target - - def update(self, target, data): - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - attribute.set(target, data.get(name)) - - # Iterate through all write-able relations and set each one. - for name, relation in six.iteritems(self.relationships): - if relation.write: - # Set each one on the target. - # FIXME: Use some deferred thing that is not `setattr`. - setattr(target, relation.key, data.get(name)) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def destroy(self): - # Grab the existing target. - target = self.read() - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'destroy', self, target): - authz.unauthorized() - - # Remove the object from the session. - self.session.delete(target) - - def _resolve_relation(self, target): - # Resolve the relationship key. - key = None - related = type(self)._related_models - res_clss = type(target), - while res_clss: - res_cls_next = [] - for res_cls in res_clss: - if res_cls in related: - key = self.relationships[related[res_cls]].key - break - - if res_cls.__bases__: - res_cls_next.extend(res_cls.__bases__) - - else: - # Nope; continue - res_clss = res_cls_next - continue - - # Able to link - break - - else: - # Not able to link - return None - - # Able to link - return key - - def relate(self, target, other): - # Resolve the relationship key. - key = self._resolve_relation(other) - if not key: - raise BadRequest({ - '__all__': "Unable to link a '%s' to a '%s'." % ( - type(target).__name__, type(other).__name__)}) - - # Grab the set_ in question. - set_ = getattr(target, key) - - # Append the relationship. - set_.append(other) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def unrelate(self, target, other): - # Resolve the relationship key. - key = self._resolve_relation(other) - if not key: - raise BadRequest({ - '__all__': "Unable to link a '%s' to a '%s'." % ( - type(target).__name__, type(other).__name__)}) - - # Grab the set_ in question. - set_ = getattr(target, key) - - # Append the relationship. - set_.remove(other) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def read_related(self, target, resource, key): - # Grab the set_ in question. - set_ = getattr(target, key) - - # Build the related items. - qs = set_ - - if isinstance(qs, Iterable): - if self.request.user: - # Filter the queryset by asserting authorization. - qs = resource.meta.authorization.filter( - self.request.user, 'read', resource, qs) - - else: - if self.request.user: - # Ensure we can access this. - authz = self.meta.authorization - if not authz.is_authorized( - self.request.user, 'read', resource, qs): - authz.unauthorized() - - # Return the queryset. - return qs - - def clean_related(self, relation, value): - # Grab the model in question. - model = relation.resource.meta.model - - # Attempt to build a query to `get` the model. - target = self.session.query(model).get(value) - if target is not None: - # Found a target from the value. - return target - - # Found nothing. diff --git a/armet/decorators.py b/armet/decorators.py deleted file mode 100644 index 610b625..0000000 --- a/armet/decorators.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .exceptions import ImproperlyConfigured -import six -from . import utils - - -class route: - """ - Mounts a resource on the passed URL mount point by invoking the - `mount` classmethod. - - @example - @route('/') - class Resource(resources.Resource): - # ... - """ - - def __init__(self, *args, **kwargs): - # Just store the arguments. - self.args = args - self.kwargs = kwargs - - def __call__(self, resource): - # Is the resources a function? - if callable(resource) and not isinstance(resource, type): - # Yes; call the resource decorator. - import armet - resource = armet.resource(**self.kwargs)(resource) - - # Ensure the resource has a `mount` classmethod. - if not hasattr(resource, 'mount'): - raise ImproperlyConfigured( - 'The {} resource doesn\'t have a `mount` method.'.format( - resource.meta.name)) - - # Hook up the resource at the mount point. - resource.mount(*self.args) - - # Return the resource - return resource - - -def asynchronous(resource): - """Instructs a decorated resource that it is to be asynchronous. - - An asynchronous resource means that `response.close()` must be called - explicitly as returning from a method (eg. `get`) does not close - the connection. - - @note - This can also be configured by setting `asynchronous` to `True` - on `.Meta`. The benefit of the decorator is that this - can be applied to specific methods as well as the entire class - body. - """ - # TODO: Check for gevent support... - - # Flip the asynchronous switch. - resource.meta.asynchronous = True - - # Return the resource - return resource - - -#! Mapping of lightweight resources. -_resources = {} - -#! Mapping of functional handlers for the lightweight resources. -_handlers = {} - - -def resource(**kwargs): - """Wraps the decorated function in a lightweight resource.""" - def inner(function): - name = kwargs.pop('name', None) - if name is None: - name = utils.dasherize(function.__name__) - - methods = kwargs.pop('methods', None) - if isinstance(methods, six.string_types): - # Tuple-ify the method if we got just a string. - methods = methods, - - # Construct a handler. - handler = (function, methods) - - if name not in _resources: - # Initiate the handlers list. - _handlers[name] = [] - - # Construct a light-weight resource using the passed kwargs - # as the arguments for the meta. - from armet import resources - kwargs['name'] = name - - class LightweightResource(resources.Resource): - Meta = type(str('Meta'), (), kwargs) - - def route(self, request, response): - for handler, methods in _handlers[name]: - if methods is None or request.method in methods: - return handler(request, response) - - resources.Resource.route(self) - - # Construct and add this resource. - _resources[name] = LightweightResource - - # Add this to the handlers. - _handlers[name].append(handler) - - # Return the resource. - return _resources[name] - - # Return the inner method. - return inner diff --git a/armet/deserializers/__init__.py b/armet/deserializers/__init__.py deleted file mode 100644 index 752a272..0000000 --- a/armet/deserializers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from .base import Deserializer -from .json import JSONDeserializer -from .url import URLDeserializer - -__all__ = [ - 'Deserializer', - 'JSONDeserializer', - 'URLDeserializer' -] diff --git a/armet/deserializers/base.py b/armet/deserializers/base.py deleted file mode 100644 index 8c46f0a..0000000 --- a/armet/deserializers/base.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class Deserializer(object): - - #! Applicable media types for this deserializer. - media_types = () - - def deserialize(self, request=None, text=None): - """Parses the request content into a format consumable by python. - - @throws ValueError - To indicate this deserializer cannot deserialize the - passed text. - """ - - if text is None: - # Read in the text from the request. - text = request.read() - - return text diff --git a/armet/deserializers/json.py b/armet/deserializers/json.py deleted file mode 100644 index e422811..0000000 --- a/armet/deserializers/json.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import ujson as json -from .base import Deserializer -from armet import media_types - - -class JSONDeserializer(Deserializer): - - media_types = media_types.JSON - - def deserialize(self, request=None, text=None, encoding='utf8'): - if text is None: - # Read in the text from the request. - text = request.read() - - # Ensure the text is decoded. - if isinstance(text, six.binary_type): - text = text.decode(encoding) - - try: - # Attempt to deserialize the text. - return json.loads(text) - - except TypeError: - # Failed; possibly null. - raise ValueError diff --git a/armet/deserializers/url.py b/armet/deserializers/url.py deleted file mode 100644 index a4c5fb0..0000000 --- a/armet/deserializers/url.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Deserializer -from armet import media_types - - -if six.PY3: - from urllib.parse import parse_qsl - -else: - from urlparse import parse_qsl - - -class URLDeserializer(Deserializer): - - media_types = media_types.URL - - def deserialize(self, request=None, text=None, encoding='utf8'): - - if text is None: - # Read in the text from the request. - text = request.read() - - # Ensure we don't attempt to deserialize nothing. - if text is None: - raise ValueError - - try: - # Attempt to desserialize the URL using the - # URL decoder. - data = {} - for name, value in parse_qsl(text, keep_blank_values=True): - # Ensure values are properly decoded if neccessary. - if isinstance(value, six.binary_type): - value = value.decode(encoding) - - if isinstance(name, six.binary_type): - name = name.decode(encoding) - - # Initialize the array. - if name not in data: - data[name] = [] - - # Append the data value. - data[name].append(value) - - return data - - except AttributeError: - # Something went wront internally; bad input. - raise ValueError diff --git a/armet/exceptions.py b/armet/exceptions.py deleted file mode 100644 index 90b2785..0000000 --- a/armet/exceptions.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class ImproperlyConfigured(BaseException): - """Something has been set up or configured incorrectly. - """ - - -class InvalidOperation(BaseException): - """ - Something is being asked to operate outside of the defined specification - of it operating. - """ - - -class ValidationError(BaseException): - """ - Something has been found invalid during the attribute clean cycle; - normally resulting from form or some type of validation. - """ - - def __init__(self, *errors): - self.errors = errors diff --git a/armet/helpers.py b/armet/helpers.py deleted file mode 100644 index 0a904fd..0000000 --- a/armet/helpers.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -def use(**kwargs): - """ - Updates the active resource configuration to the passed - keyword arguments. - - Invoking this method without passing arguments will just return the - active resource configuration. - - @returns - The previous configuration. - """ - config = dict(use.config) - use.config.update(kwargs) - return config - -# Set the initial resource configuration. -use.config = {} diff --git a/armet/http/__init__.py b/armet/http/__init__.py deleted file mode 100644 index 14a3997..0000000 --- a/armet/http/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -"""Normalizes access to the HTTP libraries. -""" -from __future__ import absolute_import, unicode_literals, division -from six.moves import http_client as client -from .request import Request -from .response import Response -from . import exceptions - -__all__ = [ - 'client', - 'Request', - 'Response', - 'exceptions' -] - -try: - # Attempt to get additional status codes (added in python 3.2) - getattr(client, 'PERMANENT_REDIRECT') - getattr(client, 'PRECONDITION_REQUIRED') - getattr(client, 'TOO_MANY_REQUESTS') - getattr(client, 'REQUEST_HEADER_FIELDS_TOO_LARGE') - getattr(client, 'NETWORK_AUTHENTICATION_REQUIRED') - -except AttributeError: - # Don't have em; add them. - client.PERMANENT_REDIRECT = 308 - client.PRECONDITION_REQUIRED = 428 - client.TOO_MANY_REQUESTS = 429 - client.REQUEST_HEADER_FIELDS_TOO_LARGE = 431 - client.NETWORK_AUTHENTICATION_REQUIRED = 511 diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py deleted file mode 100644 index 7d3ba30..0000000 --- a/armet/http/exceptions.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.http import client - - -class BaseHTTPException(BaseException): - status = None - - def __init__(self, content=None, headers=None): - #! Body of the exception message. - self.content = content - - #! Additional headers to place with the response. - self.headers = headers or {} - - -class BadRequest(BaseHTTPException): # 400 - status = client.BAD_REQUEST - - -class Unauthorized(BaseHTTPException): # 401 - status = client.UNAUTHORIZED - - -class PaymentRequired(BaseHTTPException): # 402 - status = client.PAYMENT_REQUIRED - - -class Forbidden(BaseHTTPException): # 403 - status = client.FORBIDDEN - - -class NotFound(BaseHTTPException): # 404 - status = client.NOT_FOUND - - -class MethodNotAllowed(BaseHTTPException): # 405 - status = client.METHOD_NOT_ALLOWED - - def __init__(self, allowed): - super(MethodNotAllowed, self).__init__( - headers={'Allow': ', '.join(allowed)}) - - -class NotAcceptable(BaseHTTPException): # 406 - status = client.NOT_ACCEPTABLE - - -class ProxyAuthenticationRequired(BaseHTTPException): # 407 - status = client.PROXY_AUTHENTICATION_REQUIRED - - -class RequestTimeout(BaseHTTPException): # 408 - status = client.REQUEST_TIMEOUT - - -class Conflict(BaseHTTPException): # 409 - status = client.CONFLICT - - -class Gone(BaseHTTPException): # 410 - status = client.GONE - - -class LengthRequired(BaseHTTPException): # 411 - status = client.LENGTH_REQUIRED - - -class PreconditionFailed(BaseHTTPException): # 412 - status = client.PRECONDITION_FAILED - - -class RequestEntityTooLarge(BaseHTTPException): # 413 - status = client.REQUEST_ENTITY_TOO_LARGE - - -class RequestUriTooLong(BaseHTTPException): # 414 - status = client.REQUEST_URI_TOO_LONG - - -class UnsupportedMediaType(BaseHTTPException): # 415 - status = client.UNSUPPORTED_MEDIA_TYPE - - -class RequestedRangeNotSatisfiable(BaseHTTPException): # 416 - status = client.REQUESTED_RANGE_NOT_SATISFIABLE - - -class ExpectationFailed(BaseHTTPException): # 417 - status = client.EXPECTATION_FAILED - - -class UnprocessableEntity(BaseHTTPException): # 422 - status = client.UNPROCESSABLE_ENTITY - - -class Locked(BaseHTTPException): # 423 - status = client.LOCKED - - -class FailedDependency(BaseHTTPException): # 424 - status = client.FAILED_DEPENDENCY - - -class UgradeRequired(BaseHTTPException): # 426 - status = client.UPGRADE_REQUIRED - - -class InternalServerError(BaseHTTPException): # 500 - status = client.INTERNAL_SERVER_ERROR - - -class NotImplemented(BaseHTTPException): # 501 - status = client.NOT_IMPLEMENTED - - -class BadGateway(BaseHTTPException): # 502 - status = client.BAD_GATEWAY - - -class ServiceUnavailable(BaseHTTPException): # 503 - status = client.SERVICE_UNAVAILABLE - - -class GatewayTimeout(BaseHTTPException): # 504 - status = client.GATEWAY_TIMEOUT - - -class HttpVersionNotSupported(BaseHTTPException): # 505 - status = client.HTTP_VERSION_NOT_SUPPORTED - - -class InsufficientStorage(BaseHTTPException): # 507 - status = client.INSUFFICIENT_STORAGE - - -class NotExtended(BaseHTTPException): # 510 - status = client.NOT_EXTENDED diff --git a/armet/http/request.py b/armet/http/request.py deleted file mode 100644 index dd7435a..0000000 --- a/armet/http/request.py +++ /dev/null @@ -1,267 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import abc -import six -import string -import collections -import mimeparse -import weakref -from six.moves import http_cookies - - -class Headers(collections.Mapping): - """Describes a mapping abstraction over request headers. - """ - - @staticmethod - def _Header(sequence, name): - """Returns the passed header as a tuple. - - Implements a facade so that the response headers can override - this to provide a mutable sequence header. - """ - return tuple(sequence._headers.get(name, '').split(',')) - - class _Sequence(dict): - """ - Provides an implementation of a dictionary that retreives its - values as sequences. - """ - - def __init__(self, headers): - self._headers = headers - - def __missing__(self, name): - self[name] = value = self.headers._Header(self, name) - return value - - def __init__(self): - #! Internal store of the multi-valued headers as lists. - self._sequence = self._Sequence(self) - - @staticmethod - def normalize(name): - """Normalizes the case of the passed name to be Http-Header-Case.""" - return str(string.capwords(name, '-')) - - @abc.abstractmethod - def __getitem__(self, name): - """Retrieves a header with the passed name. - - @param[in] name - The case-insensitive name of the header to retrieve. - """ - - @abc.abstractmethod - def __len__(self): - """Retrieves the number of headers in the request.""" - - @abc.abstractmethod - def __iter__(self): - """Returns an iterable for all headers in the request.""" - - @abc.abstractmethod - def __contains__(self, name): - """Tests if the passed header exists in the request.""" - - def index(self, name, value): - """ - Return the index in the list of the first item whose value is x in - the values of the named header. - """ - return self._sequence[name].index(value) - - def count(self, name, value): - """ - Return the number of times a value appears in the list of the values - of the named header. - """ - return self._sequence[name].count(value) - - def getlist(self, name): - """Retrieves the passed header as a tuple of its values.""" - return self._sequence[name] - - -class Request(six.Iterator): - """Describes the RESTful request abstraction. - """ - - #! Dictionary-like interface to access headers; this should be - #! set by the dervied class to an instance of a derived Headers class. - headers = None - - def __init__(self, path, method, asynchronous, *args, **kwargs): - #! The captured path of the request, after the mount point. - #! Example: GET /api/poll/23 => '/23' - self.path = path - - #! True if we're asynchronous. - self.asynchronous = asynchronous - - # Determine the actual HTTP method; apply the override header. - override = self.headers.get('X-Http-Method-Override') - if override: - # Passed method was overriden; store override. - self.method = override.upper() - - else: - # Passed method is the actual method. - self.method = method.upper() - - #! Cookie jar full of python morsel objects that represent the - #! cookies that were sent with the request. - text = self.get('Cookie') - self.cookies = http_cookies.SimpleCookie() - if text: - self.cookies.load(str(text)) - - #! A reference to the bound resource; this is set in the resource - #! view method after traversal. - self._resource = None - - # HACK: Initialize some context sets. - self._embed_related = set() - - def bind(self, resource): - """Binds this to the passed resource object. - - This is used so that the request and response classes can access - metadata and configuration on the resource handling this request - so helper methods on the request and response like `serialize` work - in full knowledge of configuration supplied to the resource. - """ - self._resource = weakref.proxy(resource) - - @property - def resource(self): - return self._resource - - @property - def protocol(self): - """Retrieves the upper-cased version of the protocol (eg. HTTP).""" - raise NotImplementedError() - - @property - def host(self): - """Retrieves the hostname, normally from the `Host` header.""" - return self.headers.get('Host') or '127.0.0.1' - - @property - def mount_point(self): - """Retrieves the mount point portion of the path of this request.""" - raise NotImplementedError() - - @property - def query(self): - """Retrieves the text after the first ? in the path.""" - raise NotImplementedError() - - @property - def uri(self): - """Returns the complete URI of the request.""" - raise NotImplementedError() - - @property - def encoding(self): - """ - The name of the encoding used to decode the stream’s bytes - into strings, and to encode strings into bytes. - - Reads the charset value from the `Content-Type` header, if available; - else, returns nothing. - """ - # Get the `Content-Type` header, if available. - content_type = self.headers.get('Content-Type') - if content_type: - # Parse out the primary type and parameters from the media type. - ptype, _, params = mimeparse.parse_mime_type(content_type) - - # Return the specified charset or the default depending on the - # primary type. - default = 'utf-8' if ptype == 'application' else 'iso-8859-1' - return params.get('charset', default) - - def _read(self): - """Read and return the request data. - - @note Connectors should override this method. - """ - return None - - def read(self, deserialize=False, format=None): - """Read and return the request data. - - @param[in] deserialize - True to deserialize the resultant text using a determiend format - or the passed format. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - """ - - if deserialize: - data, _ = self.deserialize(format=format) - return data - - content = self._read() - - if not content: - return '' - - if type(content) is six.binary_type: - content = content.decode(self.encoding) - - return content - - def deserialize(self, format=None): - """Deserializes the request body using a determined deserializer. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - - @returns - A tuple of the deserialized data and an instance of the - deserializer used. - """ - return self._resource.deserialize(self, format=format) - - def __len__(self): - """Returns the length of the request body, if known.""" - length = self.headers.get('Content-Length') - return int(length) if length else 0 - - def __getitem__(self, name): - """Retrieves a header with the passed name.""" - return self.headers[name] - - def get(self, name, default=None): - """Retrieves a header with the passed name.""" - return self.headers.get(name, default) - - def getlist(self, name): - """ - Retrieves a the multi-valued list of the header with - the passed name. - """ - return self.headers.getlist(name) - - def __contains__(self, name): - """Tests if the passed header exists in the request.""" - return name in self.headers - - def keys(self): - """Return a new view of the header names.""" - return self.headers.keys() - - def values(self): - """Return a new view of the header values.""" - return self.headers.values() - - def items(self): - """Return a new view of the headers.""" - return self.headers.items() diff --git a/armet/http/response.py b/armet/http/response.py deleted file mode 100644 index 8973fd8..0000000 --- a/armet/http/response.py +++ /dev/null @@ -1,476 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import collections -import abc -import six -import mimeparse -import weakref -import io -from armet import exceptions -from . import request, client - - -class Headers(collections.MutableMapping, request.Headers): - """Describes a mutable mapping abstraction over response headers. - """ - - class _Header(collections.MutableSequence): - """ - Provides an implementation of a mutable sequence of multi-valued - headers that are synced with the normal list of headers. - """ - - def __init__(self, sequence, name): - #! Internal reference to the first-degree headers dictionary. - self._headers = sequence._headers - self._obj = self._headers._obj - self._name = name - - #! Internal store of the value at instantiation of the header. - self._value = self._headers.get(self._name, '') - - #! Internal sequence of the list of the values of the header. - self._sequence = self._value.split(',') - - def _pull(self): - # Pull the headers from the source. - if self._value != self._headers.get(self._name, ''): - # If the header value has changed; update our sequence. - self._value = self._headers.get(self._name, '') - self._sequence = self._value.split(',') - - def __getitem__(self, index): - # Retrieve the header value at the passed index. - self._pull() - return self._sequence[index] - - def __len__(self): - self._pull() - return len(self._sequence) - - def _push(self): - # Push the headers to the source. - self._value = ','.join(self._sequence) - self._headers[self._name] = self._value - - def __setitem__(self, index, value): - # Store the value at the passed index - self._obj._assert_open() - self._sequence[index] = value - self._push() - - def __delitem__(self, index): - # Remove the value at the passed index. - self._obj._assert_open() - del self._sequence[index] - self._push() - - def insert(self, index, value): - # Insert the header value at the passed index. - self._obj._assert_open() - self._sequence.insert(index, value) - self._push() - - @abc.abstractmethod - def __setitem__(self, name, value): - """Stores a header with the passed name. - - @param[in] name - The name to store the header as. This is passed through - `Headers.normalize` before storing on the response. - - @param[in] value - The value to store for the header; for multi-valued headers, - this can be a comma-separated list of values. - """ - - @abc.abstractmethod - def __delitem__(self, name): - """Removes a header with the passed name. - - @param[in] name - The case-insensitive name of the header to remove - from the response. - """ - - def append(self, name, value): - """Add a value to the end of the list for the named header.""" - return self._sequence[name].append(value) - - def extend(self, name, values): - """Extend the list for the named header by appending all values.""" - return self._sequence[name].extend(values) - - def insert(self, name, index, value): - """Insert a value at the passed index in the named header.""" - return self._sequence[name].insert(index, value) - - def remove(self, name, value): - """ - Remove the first item with the passed value from the - list for the named header. - """ - return self._sequence[name].remove(value) - - def popvalue(self, name, index=None): - """Remove the item at the given position in the named header list.""" - return self._sequence[name].pop(index) - - def sort(self, name): - """Sort the items of the list, in place.""" - return self._sequence[name].sort() - - def reverse(self, name): - """Reverse the elements of the list, in place.""" - return self._sequence[name].reverse() - - -class Response(object): - """Describes the RESTful response abstraction. - """ - - #! Dictionary-like interface to access headers; this should be - #! set by the dervied class to an instance of a derived Headers class. - headers = None - - def __init__(self, asynchronous, *args, **kwargs): - #! True if the response object is closed. - self._closed = False - - #! True if the response object is streaming. - self.streaming = False - - #! True if we're asynchronous. - self.asynchronous = asynchronous - - #! Default the status code to OK. - self.status = client.OK - - #! A reference to the bound resource; this is set in the resource - #! view method after traversal. - self._resource = None - - #! Explicit declaration of character encoding to use when - #! writing data to the response body. - #! Defaults to parsing content-type and determining encoding - #! via the standard rules. - self._encoding = None - - #! The underlying file stream to write incoming data to. - self._stream = io.BytesIO() - - #! The content chunk to return to the client. - self._body = None - - #! The length of the response. - self._length = 0 - - def require_not_closed(self): - """Raises an exception if the response is closed.""" - if self.closed: - raise exceptions.InvalidOperation('Response is closed.') - - def require_open(self): - """Raises an exception if the response is not open.""" - self.require_not_closed() - if self.streaming: - raise exceptions.InvalidOperation('Response is streaming.') - - @property - def status(self): - """Gets the status code of the response.""" - raise NotImplementedError() - - @status.setter - def status(self, value): - """Sets the status code of the response.""" - raise NotImplementedError() - - @property - def body(self): - """Returns the current value of the response body.""" - return self._body - - @body.setter - def body(self, value): - """Sets the response body to the passed value. - - @note - During asynchronous or streaming responses, remember that - the `body` property refers to the portion of the response *not* - sent to the client. - """ - self._body = value - - def bind(self, resource): - """Binds this to the passed resource object. - - @sa armet.http.request.Request.bind - """ - self._resource = weakref.proxy(resource) - - @property - def resource(self): - return self._resource - - @property - def encoding(self): - if self._encoding is not None: - # Encoding has been set manually. - return self._encoding - - # Get the `Content-Type` header, if available. - content_type = self.headers.get('Content-Type') - if content_type: - # Parse out the primary type and parameters from the media type. - ptype, _, params = mimeparse.parse_mime_type(content_type) - - # Return the specified charset or the default depending on the - # primary type. - default = 'utf-8' if ptype == 'application' else 'iso-8859-1' - return params.get('charset', default) - - # No encoding found. - - @encoding.setter - def encoding(self, value): - # Explicitly set the character encoding to use. - self._encoding = value - - @abc.abstractmethod - def close(self): - """Flush and close the stream. - - This is called automatically by the base resource on resources - unless the resource is operating asynchronously; in that case, - this method MUST be called in order to signal the end of the request. - If not the request will simply hang as it is waiting for some - thread to tell it to return to the client. - """ - - # Ensure we're not closed. - self.require_not_closed() - - if not self.streaming or self.asynchronous: - # We're not streaming, auto-write content-length if not - # already set. - if 'Content-Length' not in self.headers: - self.headers['Content-Length'] = self.tell() - - # Flush out the current buffer. - self.flush() - - # We're done with the response; inform the HTTP connector - # to close the response stream. - self._closed = True - - @property - def closed(self): - """True if the stream is closed.""" - return self._closed - - def tell(self): - """Return the current stream position.""" - return self._length - - def write(self, chunk, serialize=False, format=None): - """Writes the given chunk to the output buffer. - - @param[in] chunk - Either a byte array, a unicode string, or a generator. If `chunk` - is a generator then calling `self.write()` is - equivalent to: - - @code - for x in : - self.write(x) - self.flush() - @endcode - - @param[in] serialize - True to serialize the lines in a determined serializer. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - """ - - # Ensure we're not closed. - self.require_not_closed() - - if chunk is None: - # There is nothing here. - return - - if serialize or format is not None: - # Forward to the serializer to serialize the chunk - # before it gets written to the response. - self.serialize(chunk, format=format) - return # `serialize` invokes write(...) - - if type(chunk) is six.binary_type: - # Update the stream length. - self._length += len(chunk) - - # If passed a byte string, we hope the user encoded it properly. - self._stream.write(chunk) - - elif isinstance(chunk, six.string_types): - encoding = self.encoding - if encoding is not None: - # If passed a string, we can encode it for the user. - chunk = chunk.encode(encoding) - - else: - # Bail; we don't have an encoding. - raise exceptions.InvalidOperation( - 'Attempting to write textual data without an encoding.') - - # Update the stream length. - self._length += len(chunk) - - # Write the encoded data into the byte stream. - self._stream.write(chunk) - - elif isinstance(chunk, collections.Iterable): - # If passed some kind of iterator, attempt to recurse into - # oblivion. - for section in chunk: - self.write(section) - - else: - # Bail; we have no idea what to do with this. - raise exceptions.InvalidOperation( - 'Attempting to write something not recognized.') - - def serialize(self, data, format=None): - """Serializes the data into this response using a serializer. - - @param[in] data - The data to be serialized. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - - @returns - A tuple of the serialized text and an instance of the - serializer used. - """ - return self._resource.serialize(data, response=self, format=format) - - def flush(self): - """Flush the write buffers of the stream. - - This results in writing the current contents of the write buffer to - the transport layer, initiating the HTTP/1.1 response. This initiates - a streaming response. If the `Content-Length` header is not given - then the chunked `Transfer-Encoding` is applied. - """ - - # Ensure we're not closed. - self.require_not_closed() - - # Pull out the accumulated chunk. - chunk = self._stream.getvalue() - self._stream.truncate(0) - self._stream.seek(0) - - # Append the chunk to the body. - self.body = chunk if (self._body is None) else (self._body + chunk) - - if self.asynchronous: - # We are now streaming because we're asynchronous. - self.streaming = True - - def send(self, *args, **kwargs): - """Writes the passed chunk and flushes it to the client.""" - self.write(*args, **kwargs) - self.flush() - - def end(self, *args, **kwargs): - """ - Writes the passed chunk, flushes it to the client, - and terminates the connection. - """ - self.send(*args, **kwargs) - self.close() - - def __getitem__(self, name): - """Retrieves a header with the passed name.""" - return self.headers[name] - - def __setitem__(self, name, value): - """Stores a header with the passed name.""" - self.headers[name] = value - - def __delitem__(self, name): - """Removes a header with the passed name.""" - del self.headers - - def __len__(self): - """Retrieves the actual length of the response.""" - return self.tell() - - def __nonzero__(self): - """Test if the response is closed.""" - return not self._closed - - def __bool__(self): - """Test if the response is closed.""" - return not self._closed - - def __contains__(self, name): - """Tests if the passed header exists in the response.""" - return name in self.headers - - def append(self, name, value): - """Add a value to the end of the list for the named header.""" - return self.headers.append(name, value) - - def extend(self, name, values): - """Extend the list for the named header by appending all values.""" - return self.headers.extend(name, values) - - def insert(self, name, index, value): - """Insert a value at the passed index in the named header.""" - return self.headers.insert(index, value) - - def remove(self, name, value): - """ - Remove the first item with the passed value from the - list for the named header. - """ - return self.headers.remove(name, value) - - def popvalue(self, name, index=None): - """Remove the item at the given position in the named header list.""" - return self.headers.popvalue(name, index) - - def index(self, name, value): - """ - Return the index in the list of the first item whose value is x in - the values of the named header. - """ - return self.headers.index(name, value) - - def count(self, name, value): - """ - Return the number of times a value appears in the list of the values - of the named header. - """ - return self.headers.count(name, value) - - def sort(self, name): - """Sort the items of the list, in place.""" - return self.headers.sort(name) - - def reverse(self, name): - """Reverse the elements of the list, in place.""" - return self.headers.reverse(name) - - def getlist(self, name): - """Retrieves the passed header as a sequence of its values.""" - return self.headers.getlist(name) diff --git a/armet/media_types.py b/armet/media_types.py deleted file mode 100644 index 88e89c5..0000000 --- a/armet/media_types.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tuples of the accepted media types for the various supported content types. - -The first media type of each respective listing is what armet will -provide for its `Content-Type` upon serialization; armet will accept -any of the listed types when receiving content or when asked to -serialize in a specific format. -""" -from __future__ import absolute_import, unicode_literals, division - -#! Multipart form data; as defined by HTML5. -#! -#! http://www.w3.org/TR/html5/constraints.html#multipart-form-data -FORM_DATA = 'multipart/form-data', - -#! URL encoded form data; as defined by HTML5. -#! -#! http://www.w3.org/TR/html5/constraints.html#url-encoded-form-data -URL = 'application/x-www-form-urlencoded', - -#! JavaScript Object Notation, is a text-based open standard designed -#! for human-readable data interchange. -JSON = ( - # Offical; as per RFC 4627. - 'application/json', - - # Widely used (thanks .) - 'application/jsonrequest', - - # Miscellaneous mimetypes that are used frequently (incorrectly). - 'application/x-json', - 'text/json; charset=utf-8', - - # Widely used (incorrectly) thanks to ruby. - 'text/x-json; charset=utf-8', -) - -#! Extensible Markup Language (XML) is a markup language that defines a set -#! of rules for encoding documents in a format that is both human-readable -#! and machine-readable. -XML = ( - # To be used when the XML is NOT human-readable as per RFC 3023. - # Our XML is not considered human-readable as it is minified before - # transfer. - 'application/xml', - - # To be used when the XML is human-readable as per RFC 3023. - 'text/xml; charset=utf-8', -) - -#! YAML is a human friendly data serialization standard -#! for all programming languages. -#! -#! @note -#! There is no standard media type for YAML. -YAML = ( - 'application/x-yaml', - 'application/yaml', - 'text/yaml; charset=utf-8', - 'text/x-yaml; charset=utf-8', -) diff --git a/armet/pagination.py b/armet/pagination.py deleted file mode 100644 index 0828c84..0000000 --- a/armet/pagination.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Implementation of a pagination interface using a combination of the HTTP/1.1 -range header and set specifiers. -""" -from armet.http import exceptions, client - -#! The range specifier to use. -RANGE_SPECIFIER = 'items' - - -def parse(specifiers): - """ - Consumes set specifiers as text and forms a generator to retrieve - the requested ranges. - - @param[in] specifiers - Expected syntax is from the byte-range-specifier ABNF found in the - [RFC 2616]; eg. 15-17,151,-16,26-278,15 - - @returns - Consecutive tuples that describe the requested range; eg. (1, 72) or - (1, 1) [read as 1 to 72 or 1 to 1]. - """ - specifiers = "".join(specifiers.split()) - for specifier in specifiers.split(','): - if len(specifier) == 0: - raise ValueError("Range: Invalid syntax; missing specifier.") - - count = specifier.count('-') - if (count and specifier[0] == '-') or not count: - # Single specifier; return as a tuple to itself. - yield int(specifier), int(specifier) - continue - - specifier = list(map(int, specifier.split('-'))) - if len(specifier) == 2: - # Range specifier; return as a tuple. - if specifier[0] < 0 or specifier[1] < 0: - # Negative indexing is not supported in range specifiers - # as stated in the HTTP/1.1 Range header specification. - raise ValueError( - "Range: Invalid syntax; negative indexing " - "not supported in a range specifier.") - - if specifier[1] < specifier[0]: - # Range must be for at least one item. - raise ValueError( - "Range: Invalid syntax; stop is less than start.") - - # Return them as a immutable tuple. - yield tuple(specifier) - continue - - # Something weird happened. - raise ValueError("Range: Invalid syntax.") - - -def paginate(request, response, items): - """Paginate an iterable during a request. - - Magically splicling an iterable in our supported ORMs allows LIMIT and - OFFSET queries. We should probably delegate this to the ORM or something - in the future. - """ - # TODO: support dynamic rangewords and page lengths - # TODO: support multi-part range requests - - # Get the header - header = request.headers.get('Range') - if not header: - # No range header; move along. - return items - - # do some validation - prefix = RANGE_SPECIFIER + '=' - if not header.find(prefix) == 0: - # This is not using a range specifier that we understand - raise exceptions.RequestedRangeNotSatisfiable() - else: - # Chop the prefix off the header and parse it - ranges = parse(header[len(prefix):]) - - ranges = list(ranges) - if len(ranges) > 1: - raise exceptions.RequestedRangeNotSatisfiable( - 'Multiple ranges in a single request is not yet supported.') - start, end = ranges[0] - - # Make sure the length is not higher than the total number allowed. - max_length = request.resource.count(items) - end = min(end, max_length) - - response.status = client.PARTIAL_CONTENT - response.headers['Content-Range'] = '%d-%d/%d' % (start, end, max_length) - response.headers['Accept-Ranges'] = RANGE_SPECIFIER - - # Splice and return the items. - items = items[start:end + 1] - return items diff --git a/armet/query/__init__.py b/armet/query/__init__.py deleted file mode 100644 index ea771ac..0000000 --- a/armet/query/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .parser import (Query, QuerySegment, NoopQuerySegment, - BinarySegmentCombinator, UnarySegmentCombinator) - -__all__ = [ - 'Query', - 'QuerySegment', - 'NoopQuerySegment', - 'BinarySegmentCombinator', - 'UnarySegmentCombinator', -] diff --git a/armet/query/constants.py b/armet/query/constants.py deleted file mode 100644 index 5094fa7..0000000 --- a/armet/query/constants.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import operator -import re - - -#! Equality -OPERATOR_EQUAL = 'exact', '==' - -#! Case-insensitve equality -OPERATOR_IEQUAL = 'iexact', '=' - -#! Less than -OPERATOR_LT = 'lt', '<' - -#! Less than or equal to -OPERATOR_LTE = 'lte', '<=' - -#! Greater than -OPERATOR_GT = 'gt', '>' - -#! Greater than or equal to -OPERATOR_GTE = 'gte', '>=' - -#! Regular expression match -OPERATOR_REGEX = 'regex', '*=' - -#! Null test -OPERATOR_ISNULL = 'isnull', None - -#! Contains (x in y) -OPERATOR_ICONTAINS = 'icontains', None - -#! The fallback to use in the case that a more specific one isn't defined. -OPERATOR_FALLBACK = OPERATOR_IEQUAL -OPERATOR_SUFFIX_FALLBACK = OPERATOR_FALLBACK[0] -OPERATOR_EQUALITY_FALLBACK = OPERATOR_FALLBACK[1] - -#! Operator map relating operations to python operations. -# TODO: the values of the operator map are somewhat unwieldy to work with -# perhaps the value stored in a Segment() type should instead be one of -# the keys instead of the values. The values just show a convenient thing -# to work with in pythonland. -OPERATOR_MAP = { - OPERATOR_EQUAL: operator.eq, - OPERATOR_IEQUAL: lambda x, y: x.lower() == y.lower(), - OPERATOR_LT: operator.lt, - OPERATOR_LTE: operator.le, - OPERATOR_GT: operator.gt, - OPERATOR_GTE: operator.ge, - OPERATOR_REGEX: lambda x, y: re.search(y, x), - OPERATOR_ISNULL: lambda x: x is None, - OPERATOR_ICONTAINS: operator.contains, -} - -#! Operator set containing all operators. -OPERATORS = set(OPERATOR_MAP.keys()) - -#! Operators restricted to suffixed paths (foo.gte=bar) -OPERATOR_SUFFIXES = set(filter(None, map(operator.itemgetter(0), OPERATORS))) - -#! Operators restricted to equality comparators (foo>=bar) -OPERATOR_EQUALITIES = set(filter(None, map(operator.itemgetter(1), OPERATORS))) - -#! Operator map limited to suffixed paths {"gte": operator.ge} -OPERATOR_SUFFIX_MAP = dict( - filter(operator.itemgetter(0), - map(lambda x: (x[0][0], x[1]), - OPERATOR_MAP.items()))) - -#! Operator map limited to equality checks {">=": operator.ge} -OPERATOR_EQUALITY_MAP = dict( - filter(operator.itemgetter(0), - map(lambda x: (x[0][1], x[1]), - OPERATOR_MAP.items()))) - -#! Negation -PATH_NEGATION = 'not' -OPERATOR_NEGATION = '!' - -NEGATION = (PATH_NEGATION, OPERATOR_NEGATION) - -#! Logical -LOGICAL_AND = '&' -LOGICAL_OR = ';' - -#! Path separator -SEP_PATH = '.' - -#! Value separator -SEP_VALUE = ',' - -#! Directive initiator -DIRECTIVE = ':' - -#! Grouping characters -GROUP_BEGIN = '(' -GROUP_END = ')' diff --git a/armet/query/parser.py b/armet/query/parser.py deleted file mode 100644 index 0e9e1f1..0000000 --- a/armet/query/parser.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from six.moves import cStringIO as StringIO -import operator -import itertools -from . import constants - - -#! Operation to combinator map. -COMBINATORS = { - constants.LOGICAL_AND: operator.and_, - constants.LOGICAL_OR: operator.or_} - - -#! Set of characters that begin an operator. -OPERATOR_BEGIN_CHARS = set(x[0] for _, x in constants.OPERATORS if x) -OPERATOR_BEGIN_CHARS.add(constants.NEGATION[1]) - -#! Dictionary of operator symbols to operators. -OPERATOR_SYMBOL_MAP = dict((v, k) for k, v in constants.OPERATORS if v) - - -#! List of operator keywords. -OPERATOR_KEYWORDS = set(k for k, _ in constants.OPERATORS) - - -# Make a reversed suffix map for convenient stringification. -REVERSED_OPERATOR_SUFFIX_MAP = dict( - (v, k) for k, v in constants.OPERATOR_SUFFIX_MAP.items() -) - - -class Query(object): - """Represents a complete query expression. - """ - - def __init__(self, original, parsed): - # Keep a copy of the original querystring. - self.original = original - self.parsed = parsed - - -class QuerySegment(object): - """ - Represents a single query segment with a subject path (`x.a.g`), - an operator (`=` or `<=`), optional directives (`sort`), and - a set of values (`5,12,56`). - """ - - def __init__(self, **kwargs): - #! Path to the attribute being tested (as a list of segments). - self.path = kwargs.get('path', []) - - #! This is the operator that is being applied to the attribute path. - self.operator = kwargs.get( - 'operator', - constants.OPERATOR_MAP[constants.OPERATOR_IEQUAL] - ) - - #! Negation; if this operation has been negated. - self.negated = kwargs.get('negated', False) - - #! Directives. Directives are a key-value way of specifying commands - #! on an attribute path. - self.directives = kwargs.get('directives', []) - - #! Values. Set of values that the attribute path is being checked - #! against. Only one has to match. - self.values = kwargs.get('values', []) - - def __repr__(self): - return str(self) - - def __str__(self): - """ - Format this query segment in a human-readable representation - intended for debugging. - """ - - o = StringIO() - - o.write('(') - - if self.negated: - o.write('not ') - - o.write('.'.join(self.path)) - - if self.values: - o.write(' :%s ' % REVERSED_OPERATOR_SUFFIX_MAP[self.operator]) - - o.write(' | '.join(map(lambda x: "'{}'".format(str(x)), self.values))) - - o.write(')') - return o.getvalue() - - -class NoopQuerySegment(object): - """A query segment that doesn't perform an operation. For the purposes - of binary and unary combinations, this should be treated as True. - A NoopQuerySegment should only be encountered when the entire query string - is missing.""" - - def __repr__(self): - return str(self) - - def __str__(self): - return "TRUE" - - -class BinarySegmentCombinator(object): - """ - Represents the combination of 2 query segments `(x=y)&(y=z)` - """ - - def __init__(self, left, right, operation=operator.and_): - self.left = left - self.right = right - self.operation = operation - - def __repr__(self): - return str(self) - - def __str__(self): - combinators = { - operator.and_: 'AND', - operator.or_: 'OR' - } - - return "{} {} {}".format( - str(self.left), - combinators[self.operation], - str(self.right) - ) - - -class UnarySegmentCombinator(object): - """ - Represents a unary combination of 2 query segments. `!(x=y)` - """ - def __init__(self, operand, operation=operator.not_): - self.operand = operand - self.operation = operation - - def __repr__(self): - return str(self) - - def __str__(self): - return "{} {}".format('NOT', self.operand) - - -def parse(text, encoding='utf8'): - """Parse the querystring into a normalized form.""" - - # Decode the text if we got bytes. - if isinstance(text, six.binary_type): - text = text.decode(encoding) - - return Query(text, split_segments(text)) - - -def reset_stringio(buf): - # This combination of functions happens way too often in here. - buf.truncate(0) - buf.seek(0) - - -def split_segments(text, closing_paren=False): - """Return objects representing segments.""" - buf = StringIO() - - # The segments we're building, and the combinators used to combine them. - # Note that after this is complete, this should be true: - # len(segments) == len(combinators) + 1 - # Thus we can understand the relationship between segments and combinators - # like so: - # s1 (c1) s2 (c2) s3 (c3) where sN are segments and cN are combination - # functions. - # TODO: Figure out exactly where the querystring died and post cool - # error messages about it. - segments = [] - combinators = [] - - # A flag dictating if the last character we processed was a group. - # This is used to determine if the next character (being a combinator) - # is allowed to - last_group = False - - # The recursive nature of this function relies on keeping track of the - # state of iteration. This iterator will be passed down to recursed calls. - iterator = iter(text) - - # Detection for exclamation points. only matters for this situation: - # foo=bar&!(bar=baz) - last_negation = False - - for character in iterator: - if character in COMBINATORS: - - if last_negation: - buf.write(constants.OPERATOR_NEGATION) - - # The string representation of our segment. - val = buf.getvalue() - reset_stringio(buf) - - if not last_group and not len(val): - raise ValueError('Unexpected %s.' % character) - - # When a group happens, the previous value is empty. - if len(val): - segments.append(parse_segment(val)) - - combinators.append(COMBINATORS[character]) - - elif character == constants.GROUP_BEGIN: - # Recursively go into the next group. - - if buf.tell(): - raise ValueError('Unexpected %s' % character) - - seg = split_segments(iterator, True) - - if last_negation: - seg = UnarySegmentCombinator(seg) - - segments.append(seg) - - # Flag that the last entry was a grouping, so that we don't panic - # when the next character is a logical combinator - last_group = True - continue - - elif character == constants.GROUP_END: - # Build the segment for anything remaining, and then combine - # all the segments. - val = buf.getvalue() - - # Check for unbalanced parens or an empty thing: foo=bar&();bar=baz - if not buf.tell() or not closing_paren: - raise ValueError('Unexpected %s' % character) - - segments.append(parse_segment(val)) - return combine(segments, combinators) - - elif character == constants.OPERATOR_NEGATION and not buf.tell(): - last_negation = True - continue - - else: - if last_negation: - buf.write(constants.OPERATOR_NEGATION) - if last_group: - raise ValueError('Unexpected %s' % character) - buf.write(character) - - last_negation = False - last_group = False - else: - # Check and see if the iterator exited early (unbalanced parens) - if closing_paren: - raise ValueError('Expected %s.' % constants.GROUP_END) - - if not last_group: - # Add the final segment. - segments.append(parse_segment(buf.getvalue())) - - # Everything completed normally, combine all the segments into one - # and return them. - return combine(segments, combinators) - - -def combine(segments, combinators): - # We get [a,b,c] in segments and combinators that should be applied as - # [a(op)b, result(op)c] - operands = iter(segments) - operators = iter(combinators) - first = next(operands) - reducer = lambda x, y: BinarySegmentCombinator(x, y, next(operators)) - return six.moves.reduce(reducer, operands, first) - - -def parse_directive(key): - """ - Takes a key of type (foo:bar) and returns either the key and the - directive, or the key and None (for no directive.) - """ - if constants.DIRECTIVE in key: - return key.split(constants.DIRECTIVE, 1) - else: - return key, None - - -def parse_segment(text): - "we expect foo=bar" - - if not len(text): - return NoopQuerySegment() - - q = QuerySegment() - - # First we need to split the segment into key/value pairs. This is done - # by attempting to split the sequence for each equality comparison. Then - # discard any that did not split properly. Then chose the smallest key - # (greedily chose the first comparator we encounter in the string) - # followed by the smallest value (greedily chose the largest comparator - # possible.) - - # translate into [('=', 'foo=bar')] - equalities = zip(constants.OPERATOR_EQUALITIES, itertools.repeat(text)) - # Translate into [('=', ['foo', 'bar'])] - equalities = map(lambda x: (x[0], x[1].split(x[0], 1)), equalities) - # Remove unsplit entries and translate into [('=': ['foo', 'bar'])] - # Note that the result from this stage is iterated over twice. - equalities = list(filter(lambda x: len(x[1]) > 1, equalities)) - # Get the smallest key and use the length of that to remove other items - key_len = len(min((x[1][0] for x in equalities), key=len)) - equalities = filter(lambda x: len(x[1][0]) == key_len, equalities) - - # Get the smallest value length. thus we have the earliest key and the - # smallest value. - op, (key, value) = min(equalities, key=lambda x: len(x[1][1])) - - key, directive = parse_directive(key) - if directive: - op = constants.OPERATOR_EQUALITY_FALLBACK - q.directive = directive - - # Process negation. This comes in both foo.not= and foo!= forms. - path = key.split(constants.SEP_PATH) - last = path[-1] - - # Check for != - if last.endswith(constants.OPERATOR_NEGATION): - last = last[:-1] - q.negated = not q.negated - - # Check for foo.not= - if last == constants.PATH_NEGATION: - path.pop(-1) - q.negated = not q.negated - - q.values = value.split(constants.SEP_VALUE) - - # Check for suffixed operators (foo.gte=bar). Prioritize suffixed - # entries over actual equality checks. - if path[-1] in constants.OPERATOR_SUFFIXES: - - # The case where foo.gte<=bar, which obviously makes no sense. - if op not in constants.OPERATOR_FALLBACK: - raise ValueError( - 'Both path-style operator and equality style operator ' - 'provided. Please provide only a single style operator.') - - q.operator = constants.OPERATOR_SUFFIX_MAP[path[-1]] - path.pop(-1) - else: - q.operator = constants.OPERATOR_EQUALITY_MAP[op] - - if not len(path): - raise ValueError('No attribute navigation path provided.') - - q.path = path - - return q diff --git a/armet/relationship.py b/armet/relationship.py deleted file mode 100644 index 51dcd2a..0000000 --- a/armet/relationship.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from importlib import import_module - - -class Relationship(object): - """ - Track a relationship on a specific key of a target object. - - NOTE: The idea is to do eventual self-discovery of the resource argument. - """ - - def __init__(self, key, resource, link=True, write=False, list=False): - self.key = key - self._resource = resource - self._resource_cls = None - self.link = link - self.list = list - self.write = write - - @property - def resource(self): - if not self._resource_cls: - if isinstance(self._resource, six.string_types): - parts = self._resource.split('.') - mod = import_module('.'.join(parts[:-1])) - self._resource_cls = getattr(mod, parts[-1]) - - else: - self._resource_cls = self._resource - - return self._resource_cls diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py deleted file mode 100644 index 8fc1f86..0000000 --- a/armet/resources/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .resource import Resource -from .managed import ManagedResource -from .model import ModelResource - -__all__ = [ - 'Resource', - 'ManagedResource', - 'ModelResource' -] diff --git a/armet/resources/managed/__init__.py b/armet/resources/managed/__init__.py deleted file mode 100644 index 50940b0..0000000 --- a/armet/resources/managed/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import ManagedResource as BaseManagedResource -from .meta import ManagedResourceBase - -__all__ = [ - 'ManagedResource' -] - - -class ManagedResource( - six.with_metaclass(ManagedResourceBase, BaseManagedResource)): - """Implements the RESTful resource protocol for managed resources. - """ diff --git a/armet/resources/managed/base.py b/armet/resources/managed/base.py deleted file mode 100644 index 587d748..0000000 --- a/armet/resources/managed/base.py +++ /dev/null @@ -1,498 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import logging -from collections import Sequence, MutableSequence, Iterable -from armet import http, pagination -from armet.exceptions import ValidationError -from armet.resources.resource import base - - -logger = logging.getLogger(__name__) - - -class ManagedResource(base.Resource): - """Implements the RESTful resource protocol for managed resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.ManagedResource` (defined in - the `__init__.py`). - """ - - class Meta: - # The managed resource access pattern. - # Traversal is handled a bit more dyanmically than allowed with - # the simple pattern syntax. - patterns = [ - r'^' - r'(?:\:(?P[^/\(\)]*))?' - r'(?:\((?P[^/]*)\))?' - r'(?:/(?P[^/]+?))?' - r'(?:/(?P.+?))??' - r'(?:\.(?P[^/]+?))??/??$' - ] - - #! An ordered dictionary of attributes that are gathered from - #! attributes defined directly on the resource. - #! - #! @code - #! from armet import resources - #! - #! class Resource(resources.Resource): - #! name = resources.Attribute() - #! created = resources.Attribute('created') - #! @endcode - attributes = None - - #! The slug that is used to identify the resource an item is being - #! requested. None if a list is being requested. - slug = None - - @classmethod - def parse(cls, path): - result = super(ManagedResource, cls).parse(path) - if result: - # Found something; parse the result. - resource, data, end = result - - # Normalize the list arguments. - for sep, name in (('.', 'extensions'), (':', 'directives')): - if data[name]: - data[name] = data[name].split(sep) - - else: - data[name] = [] - - # Reset the result. - result = resource, data, end - - # Return what we got. - return result - - def __init__(self, *args, **kwargs): - super(ManagedResource, self).__init__(*args, **kwargs) - - #! Map of errors that have occurred in the clean cycle. - self._errors = {} - - @property - def allowed_operations(self): - """Retrieves the allowed operations for this request.""" - if self.slug is not None: - return self.meta.detail_allowed_operations - - return self.meta.list_allowed_operations - - def assert_operations(self, *args): - """Assets if the requested operations are allowed in this context.""" - if not set(args).issubset(self.allowed_operations): - raise http.exceptions.Forbidden() - - def make_response(self, data=None): - """Fills the response object from the passed data.""" - if data is not None: - # Prepare the data for transmission. - data = self.prepare(data) - - # Encode the data using a desired encoder. - self.response.write(data, serialize=True) - - def prepare(self, data): - if data is None: - # No data; return nothing. - return None - - if self.path: - # Ensure we are dealing with only one segment. - if '/' in self.path: - raise http.exceptions.NotFound() - - if (isinstance(data, Iterable) - and not isinstance(data, six.string_types)): - # Resolve the sequence only if we need to. - if not isinstance(data, MutableSequence): - data = list(data) - - # Attempt to prepare each item of the iterable (as long as - # we're not a string or some sort of mapping). - for index, value in enumerate(data): - data[index] = self.item_prepare(data[index]) - - return data - - # Prepare just the singular value and return. - data = self.item_prepare(data) - if data is None: - raise http.exceptions.NotFound() - - return data - - def attribute_prepare(self, name, attribute, item): - # Run the attribute through its prepare cycle. - return attribute.prepare( - # Optional preparation cycle on the resource. - self.preparers[name]( - # Retrieves the value from the object. - self, item, attribute.get(item))) - - def item_prepare(self, item): - # If we are the root resource, clear the map. - if type(self) == self.request._resource.__class__: - self.request._embed_related.clear() - - # We've started doing this one. - self.request._embed_related.add(type(self)) - - # Check for a path first. - if self.path: - attribute = self.attributes.get(self.path) - if attribute is None: - raise http.exceptions.NotFound() - - if not attribute.read: - raise http.exceptions.Forbidden() - - return self.attribute_prepare(self.path, attribute, item) - - # Initialize the object that hold the resultant item. - obj = {} - - # Iterate through the attributes and build the object from the item. - for name, attribute in self.attributes.items(): - if attribute.include: - # Run the attribute through its prepare cycle. - obj[attribute.name] = self.attribute_prepare( - name, attribute, item) - - if type(self) == self.request._resource.__class__: - # Iterate through the relationships and build their objects. - # FIXME: This should be opt-in - for key, relationship in self.relationships.items(): - # If we did this one; skip - if relationship.resource in self.request._embed_related: - continue - - # If we are in list access and this is not okay for - # lists then skip. - if self.slug is None and not relationship.list: - continue - - # Say that we did this one. - self.request._embed_related.add(relationship.resource) - - # Construct the related resource - related = relationship.resource( - self.request, self.response) - # related.require_authentication(self.request) - - # Get the related items. - related_items = self.read_related( - item, related, relationship.key) - - # Prepare and add to the attribute. - obj[key] = related.prepare(related_items) - - # Say that we're done with this one. - self.request._embed_related = { - x for x in self.request._embed_related - if not issubclass(x, relationship.resource) - } - - # TODO: Remove all that we are. - self.request._embed_related = { - x for x in self.request._embed_related - if not isinstance(self, x) - } - - # Return the resultant object. - return obj - - def _clean(self, target, data): - # Wrap clean so that it can be extended and have validation - # errors properly handled. - - # HACK: Replace this later with passing item down through clean(...) -- - # however this is to fix a bug and should not break the API. - self.__target = target - - try: - data = self.clean(data) - - except AssertionError as ex: - self._errors['__all__'] = [str(ex)] - - except ValidationError as ex: - self._errors['__all__'] = ex.errors - - if self._errors: - # Collect errors and raise a BadRequest message to the client. - raise http.exceptions.BadRequest(self._errors) - - return data - - def clean(self, data): - if not data: - # If no data; just alias it to an empty dictionary. - data = {} - - if (isinstance(data, Sequence) - and not isinstance(data, six.string_types)): - # Attempt to clean each item of the iterable (as long as - # we're not a string or some sort of mapping). - for index, value in enumerate(data): - data[index] = self.item_clean(data[index]) - - return data - - # Clean just the singular value and return. - return self.item_clean(data) - - def item_clean(self, item): - # Iterate through the attributes and build the object from the item. - obj = {} - - for name, attribute in self.attributes.items(): - value = item.get(attribute.name) - - try: - if value is not None: - # Run the attribute through its clean cycle. - value = self.cleaners[name]( - # Micro preparation cycle on the attribute object. - self, attribute.clean(value)) - - # Check if this attribute is writeable. - if not attribute.write: - if (not self.__target - or attribute.get(self.__target) != value): - raise ValidationError('Attribute is read-only.') - - # Ensure that we don't have a null or it is provided. - if value is None or ( - isinstance(value, six.string_types) and value == ''): - if name in item and not attribute.null: - raise ValidationError('Must not be null.') - - elif attribute.required: - raise ValidationError('Must be provided.') - - except (ValueError, AssertionError) as ex: - self._errors[attribute.name] = [str(ex)] - value = None - - except ValidationError as ex: - self._errors[attribute.name] = ex.errors - value = None - - obj[name] = value - - # Iterate through write-able relations - for name, relation in six.iteritems(self.relationships): - if relation.write: - value = item.get(name) - try: - value = self.clean_related(relation, value) - obj[name] = value - - except (ValueError, AssertionError) as ex: - self._errors[attribute.name] = [str(ex)] - value = None - - except ValidationError as ex: - self._errors[attribute.name] = ex.errors - value = None - - return obj - - @property - def http_allowed_methods(self): - if self.slug is None: - # No slug means that we're accessing this as a list. - return self.meta.http_list_allowed_methods - - # A slug exists; this is being accessed as an item. - return self.meta.http_detail_allowed_methods - - def require_http_allowed_method(self, request): - # No super call as the following replaces it by checking - # only against the more specific `list_*` or `detail_*` http - # allowed methods. - - # Check against `list_*` or `detail_*` allowed methods. - if request.method not in self.http_allowed_methods: - # Current method is found to not be allowed for - # this type of access; raise. - raise http.exceptions.MethodNotAllowed(self.http_allowed_methods) - - def get(self, request, response): - """Processes a `GET` request.""" - # Ensure we're allowed to read the resource. - self.assert_operations('read') - - # Delegate to `read` to retrieve the items. - items = self.read() - - # if self.slug is not None and not items: - # # Requested a specific resource but nothing is returned. - - # # Attempt to resolve by changing what we understand as - # # a slug to a path. - # self.path = self.path + self.slug if self.path else self.slug - # self.slug = None - - # # Attempt to retreive the resource again. - # items = self.read() - - # Ensure that if we have a slug and still no items that a 404 - # is rasied appropriately. - if not items: - raise http.exceptions.NotFound() - - if (isinstance(items, Iterable) - and not isinstance(items, six.string_types)) and items: - # Paginate over the collection. - items = pagination.paginate(self.request, self.response, items) - - # Build the response object. - self.make_response(items) - - def post(self, request, response): - """Processes a `POST` request.""" - if self.slug is not None: - # Don't know what to do an item access. - raise http.exceptions.NotImplemented() - - # Ensure we're allowed to create a resource. - self.assert_operations('create') - - # Deserialize and clean the incoming object. - data = self._clean(None, self.request.read(deserialize=True)) - - # Delegate to `create` to create the item. - item = self.create(data) - - # Build the response object. - self.response.status = http.client.CREATED - self.make_response(item) - - def put(self, request, response): - """Processes a `PUT` request.""" - if self.slug is None: - # Mass-PUT is not implemented. - raise http.exceptions.NotImplemented() - - # Check if the resource exists. - target = self.read() - - # Deserialize and clean the incoming object. - data = self._clean(target, self.request.read(deserialize=True)) - - if target is not None: - # Ensure we're allowed to update the resource. - self.assert_operations('update') - - try: - # Delegate to `update` to create the item. - self.update(target, data) - - except AttributeError: - # No read method defined. - raise http.exceptions.NotImplemented() - - # Build the response object. - self.make_response(target) - - else: - # Ensure we're allowed to create the resource. - self.assert_operations('create') - - # Delegate to `create` to create the item. - target = self.create(data) - - # Build the response object. - self.response.status = http.client.CREATED - self.make_response(target) - - def delete(self, request, response): - """Processes a `DELETE` request.""" - if self.slug is None: - # Mass-DELETE is not implemented. - raise http.exceptions.NotImplemented() - - # Ensure we're allowed to destroy a resource. - self.assert_operations('destroy') - - # Delegate to `destroy` to destroy the item. - self.destroy() - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() - - def _parse_link_headers(self, header): - # Collect all the passed link headers. - links = [] - for link_header in header.split(','): - link = link_header.split(';') - attrs = {k.strip(): v for (k, v) in [ - x.split('=') for x in link[1:]]} - attrs['uri'] = link[0][1:-1] - links.append(attrs) - - # Return the collected links. - return links - - def link(self, request, response): - """Processes a `LINK` request. - - A `LINK` request is asking to create a relation from the currently - represented URI to all of the `Link` request headers. - """ - from armet.resources.managed.request import read - - if self.slug is None: - # Mass-LINK is not implemented. - raise http.exceptions.NotImplemented() - - # Get the current target. - target = self.read() - - # Collect all the passed link headers. - links = self._parse_link_headers(request['Link']) - - # Pull targets for each represented link. - for link in links: - # Delegate to a connector. - self.relate(target, read(self, link['uri'])) - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() - - def unlink(self, request, response): - """Processes a `UNLINK` request. - - A `UNLINK` request is asking to revoke a relation from the currently - represented URI to all of the `Link` request headers. - """ - from armet.resources.managed.request import read - - if self.slug is None: - # Mass-LINK is not implemented. - raise http.exceptions.NotImplemented() - - # Get the current target. - target = self.read() - - # Collect all the passed link headers. - links = self._parse_link_headers(request['Link']) - - # Pull targets for each represented link. - for link in links: - # Delegate to a connector. - self.unrelate(target, read(self, link['uri'])) - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() diff --git a/armet/resources/managed/meta.py b/armet/resources/managed/meta.py deleted file mode 100644 index 42b8cca..0000000 --- a/armet/resources/managed/meta.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import collections -from armet.exceptions import ImproperlyConfigured -from armet.attributes import Attribute -from ..resource.meta import ResourceBase -from armet.relationship import Relationship -from . import options -import copy - - -class ManagedResourceBase(ResourceBase): - - options = options.ManagedResourceOptions - - def __new__(cls, name, bases, attrs): - # Construct the class object. - self = super(ManagedResourceBase, cls).__new__(cls, name, bases, attrs) - - if not cls._is_resource(name, bases): - # This is not an actual resource. - return self - - # Gather declared attributes from ourself and base classes. - # TODO: We'll likely need a hook here for ORMs - self.attributes = attributes = collections.OrderedDict() - for base in bases: - if getattr(base, 'attributes', None): - attributes.update(base.attributes) - - for index, attribute in six.iteritems(attrs): - if isinstance(attribute, Attribute): - attributes[index] = attribute - - # Ensure all attributes are unique instances and assign names. - # This is done so that when attributes are resolved; the resolution - # cache does not clobber base resources (for inherited attributes). - for attr in attributes: - attributes[attr] = attributes[attr].clone() - if not attributes[attr].name: - attributes[attr].name = attr - - # Resolve the slug reference to an attribute. - if self.meta.slug not in attributes: - if not self.meta.abstract: - raise ImproperlyConfigured( - 'slug must reference an existing attribute') - - else: - self.meta.slug = attributes[self.meta.slug] - - # Cache access to the attribute preparation cycle. - self.preparers = preparers = {} - for key in attributes: - prepare = getattr(self, 'prepare_{}'.format(key), None) - if not prepare: - prepare = lambda self, obj, value: value - preparers[key] = prepare - - # Cache access to the attribute clean cycle. - self.cleaners = cleaners = {} - for key in attributes: - clean = getattr(self, 'clean_{}'.format(key), None) - if not clean: - clean = lambda self, value: value - cleaners[key] = clean - - # Collect all relationships and store them by their model key. - self.relationships = relationships = collections.OrderedDict() - for base in bases: - if getattr(base, 'relationships', None): - relationships.update(base.relationships) - - for name, attr in six.iteritems(attrs): - if isinstance(attr, Relationship): - relationships[name] = attr - - # Ensure all relationships are unique instances. - for attr in relationships: - relationships[attr] = copy.copy(relationships[attr]) - - # Return the constructed class object. - return self diff --git a/armet/resources/managed/options.py b/armet/resources/managed/options.py deleted file mode 100644 index 4a71d02..0000000 --- a/armet/resources/managed/options.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ..resource import options - - -def _method_to_operation(method): - if method == 'GET': - return set(['read']) - - if method == 'PUT': - return set(['update', 'create', 'delete']) - - if method == 'POST': - return set(['create']) - - if method == 'PATCH': - return set(['update', 'create']) - - if method == 'DELETE': - return set(['destroy']) - - -def _methods_to_operations(methods): - operations = set() - for method in methods: - operations.update(_method_to_operation(method)) - - return operations - - -def _operation_to_method(operation): - if operation == 'read': - return set(['GET']) - - if operation == 'update': - return set(['PUT', 'PATCH', 'LINK', 'UNLINK']) - - if operation == 'create': - return set(['PUT', 'PATCH', 'POST']) - - if operation == 'destroy': - return set(['PUT', 'DELETE']) - - -def _operations_to_methods(operations): - methods = set(['HEAD', 'OPTIONS']) - for operation in operations: - methods.update(_operation_to_method(operation)) - - return methods - - -class ManagedResourceOptions(options.ResourceOptions): - - def __init__(self, meta, name, data, bases): - # Initalize base resource options. - super(ManagedResourceOptions, self).__init__(meta, name, data, bases) - - #! List of allowed operations. - #! Resource operations are meant to generalize and blur the - #! differences between "PATCH and PUT", "PUT = create / update", - #! etc. - #! - #! If not provided and http_allowed_methods was provided instead - #! the methods are appropriately mapped; else, the default - #! configuration is provided. - self.allowed_operations = meta.get('allowed_operations') - if self.allowed_operations is None: - if meta.get('http_allowed_methods'): - self.allowed_operations = _methods_to_operations(meta.get( - 'http_allowed_methods')) - - else: - self.allowed_operations = ( - 'read', - 'create', - 'update', - 'destroy', - ) - - # Coerce http allowed methods from the - # allowed operations. - if meta.get('http_allowed_methods') is None: - if meta.get('allowed_operations'): - self.http_allowed_methods = _operations_to_methods(meta.get( - 'allowed_operations')) - - #! List of allowed HTTP methods against a whole - #! resource (eg /user); if undeclared or None, will be defaulted - #! to `http_allowed_methods`. - self.http_list_allowed_methods = meta.get( - 'http_list_allowed_methods') - - if self.http_list_allowed_methods is None: - if meta.get('list_allowed_operations'): - self.http_list_allowed_methods = _operations_to_methods( - meta.get('list_allowed_operations')) - - else: - self.http_list_allowed_methods = self.http_allowed_methods - - #! List of allowed HTTP methods against a single - #! resource (eg /user/1); if undeclared or None, will be defaulted - #! to `http_allowed_methods`. - self.http_detail_allowed_methods = meta.get( - 'http_detail_allowed_methods') - - if self.http_detail_allowed_methods is None: - if meta.get('detail_allowed_operations'): - self.http_detail_allowed_methods = _operations_to_methods( - meta.get('detail_allowed_operations')) - - else: - self.http_detail_allowed_methods = self.http_allowed_methods - - #! List of allowed operations against a whole resource. - #! If undeclared or None, will be defaulted to `allowed_operations`. - self.list_allowed_operations = meta.get('list_allowed_operations') - - if self.list_allowed_operations is None: - if meta.get('http_list_allowed_methods'): - self.list_allowed_operations = _methods_to_operations( - meta.get('http_list_allowed_methods')) - - else: - self.list_allowed_operations = self.allowed_operations - - #! List of allowed operations against a single resource. - #! If undeclared or None, will be defaulted to `allowed_operations`. - self.detail_allowed_operations = meta.get( - 'detail_allowed_operations') - - if self.detail_allowed_operations is None: - if meta.get('http_detail_allowed_methods'): - self.detail_allowed_operations = _methods_to_operations( - meta.get('http_detail_allowed_methods')) - - else: - self.detail_allowed_operations = self.allowed_operations - - #! Attribute to use for the slug or url segment - #! that identifies the resource. The slug attribute is - #! a special attribute; there are a couple of requirements. - #! One is that it must be a unique reference. A /url/slug must - #! return at most one item. Second is that as it is a special - #! attribute that is not part of the body there is not - #! a `prepare_slug` method. - self.slug = meta.get('slug') - if self.slug is None: - # The slug defaults to `id`; which on most model engines - # is the primary key. This is as good as a default as any I - # suppose. - self.slug = 'id' diff --git a/armet/resources/managed/request.py b/armet/resources/managed/request.py deleted file mode 100644 index 7367daf..0000000 --- a/armet/resources/managed/request.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -def read(resource, url): - """ - Perform a `read` request in the passed `resource` context against - the given `url`. - - Returns what a `read` would return (the managed target item). - """ - return resource._request_read(url) diff --git a/armet/resources/model/__init__.py b/armet/resources/model/__init__.py deleted file mode 100644 index 67932f2..0000000 --- a/armet/resources/model/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import ModelResource as BaseModelResource -from .meta import ModelResourceBase - -__all__ = [ - 'ModelResource' -] - - -class ModelResource(six.with_metaclass(ModelResourceBase, BaseModelResource)): - """Implements the RESTful resource protocol for model resources. - """ diff --git a/armet/resources/model/base.py b/armet/resources/model/base.py deleted file mode 100644 index ec217c6..0000000 --- a/armet/resources/model/base.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import logging -from ..managed import base - - -logger = logging.getLogger(__name__) - - -class ModelResource(base.ManagedResource): - """Implements the RESTful resource protocol for model-bound resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.ModelResource` (defined in - the `__init__.py`). - """ diff --git a/armet/resources/model/meta.py b/armet/resources/model/meta.py deleted file mode 100644 index 6edcf90..0000000 --- a/armet/resources/model/meta.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ..managed import meta -from . import options -import six - - -# Mapping of the canonical resource for a model. -_canonical_resources = {} - - -class ModelResourceBase(meta.ManagedResourceBase): - - options = options.ModelResourceOptions - - connectors = ['http', 'model'] - - _related_models_cache = None - - def __new__(cls, name, bases, attrs): - # Construct the class object. - self = super(ModelResourceBase, cls).__new__(cls, name, bases, attrs) - - if self.meta and not self.meta.abstract: - # Add this to the canonical resource dictionary. - _canonical_resources[self.meta.model] = self - - # Return the constructed class object. - return self - - @property - def _related_models(self): - if not self._related_models_cache: - if hasattr(self, 'relationships'): - # Construct a mapping of all relationships and their models. - self._related_models_cache = {} - for key, relation in six.iteritems(self.relationships): - self._related_models_cache[ - relation.resource.meta.model] = key - - return self._related_models_cache diff --git a/armet/resources/model/options.py b/armet/resources/model/options.py deleted file mode 100644 index 8528b5d..0000000 --- a/armet/resources/model/options.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.exceptions import ImproperlyConfigured -from ..managed import options - - -class ModelResourceOptions(options.ManagedResourceOptions): - - def __init__(self, meta, name, data, bases): - # Initalize base resource options. - super(ModelResourceOptions, self).__init__(meta, name, data, bases) - - #! Reference to the declarative model defined by - #! the Object Relational Mapper (ORM). - self.model = meta.get('model') - if self.model is None and not self.abstract: - raise ImproperlyConfigured( - 'Model resources must be bound to a model.') diff --git a/armet/resources/resource/__init__.py b/armet/resources/resource/__init__.py deleted file mode 100644 index d8c2d2d..0000000 --- a/armet/resources/resource/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Resource as BaseResource -from .meta import ResourceBase - -__all__ = [ - 'Resource' -] - - -class Resource(six.with_metaclass(ResourceBase, BaseResource)): - """Implements the RESTful resource protocol for abstract resources. - """ diff --git a/armet/resources/resource/base.py b/armet/resources/resource/base.py deleted file mode 100644 index 06ce78f..0000000 --- a/armet/resources/resource/base.py +++ /dev/null @@ -1,580 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import logging -import re -import six -import collections -import mimeparse -from armet import http, utils - - -logger = logging.getLogger(__name__) - - -class Resource(object): - """Implements the RESTful resource protocol for abstract resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.Resource` (defined in - the `__init__.py`). - """ - - #! Instantiated options class; contains all merged properties - #! from the class hierarchy's Meta classes. - meta = None - - #! Maps media ranges to deserializer names. - #! Generated by the metaclass. - _deserializer_map = None - - #! Maps media ranges to serializer names. - #! Generated by the metaclass. - _serializer_map = None - - def __new__(cls, request, response, *args, **kwargs): - # Parse any arguments out of the path and traverse down the - # path using any defined patterns. - cls, params = cls.traverse(request) - - # Actually construct the resource and return the instance. - obj = super(Resource, cls).__new__(cls) - - # Update our instance dictionary with the arugments from `parse`. - obj.__dict__.update(params) - - # Return our constructed instance. - return obj - - @classmethod - def redirect(cls, request, response): - """Redirect to the canonical URI for this resource.""" - if cls.meta.legacy_redirect: - if request.method in ('GET', 'HEAD',): - # A SAFE request is allowed to redirect using a 301 - response.status = http.client.MOVED_PERMANENTLY - - else: - # All other requests must use a 307 - response.status = http.client.TEMPORARY_REDIRECT - - else: - # Modern redirects are allowed. Let's have some fun. - # Hopefully you're client supports this. - # The RFC explicitly discourages UserAgent sniffing. - response.status = http.client.PERMANENT_REDIRECT - - # Terminate the connection. - response.close() - - @classmethod - def view(cls, request, response): - """ - Entry-point of the request / response cycle; Handles resource creation - and delegation. - - @param[in] requset - The HTTP request object; containing accessors for information - about the request. - - @param[in] response - The HTTP response object; contains accessors for modifying - the information that will be sent to the client. - """ - # Determine if we need to redirect. - test = cls.meta.trailing_slash - if test ^ request.path.endswith('/'): - # Construct a new URL by removing or adding the trailing slash. - path = request.path + '/' if test else request.path[:-1] - response['Location'] = '{}://{}{}{}{}'.format( - request.protocol.lower(), - request.host, - request.mount_point, - path, - '?' + request.query if request.query else '') - - # Redirect to the version with the correct trailing slash. - return cls.redirect(request, response) - - try: - # Instantiate the resource. - obj = cls(request, response) - - # Bind the request and response objects to the constructed - # resource. - request.bind(obj) - response.bind(obj) - - # Bind the request object to the resource. - # This is used to facilitate the serializer and deserializer. - obj._request = request - - # Initiate the dispatch cycle and handle its result on - # synchronous requests. - result = obj.dispatch(request, response) - - if not response.asynchronous: - # There is several things that dispatch is allowed to return. - if (isinstance(result, collections.Iterable) and - not isinstance(result, six.string_types)): - # Return the stream generator. - return cls.stream(response, result) - - else: - # Leave it up to the response to throw or write whatever - # we got back. - response.end(result) - if response.body: - # Return the body if there was any set. - return response.body - - except http.exceptions.BaseHTTPException as e: - # Something that we can handle and return properly happened. - # Set response properties from the exception. - response.status = e.status - response.headers.update(e.headers) - - if e.content: - # Write the exception body if present and close - # the response. - # TODO: Use the plain-text encoder. - response.send(e.content, serialize=True, format='json') - - # Terminate the connection and return the body. - response.close() - if response.body: - return response.body - - except Exception: - # Something unexpected happened. - # Log error message to the logger. - logger.exception('Internal server error') - - # Write a debug message for the client. - if not response.streaming and not response.closed: - response.status = http.client.INTERNAL_SERVER_ERROR - response.headers.clear() - response.close() - - @classmethod - def parse(cls, path): - """Parses out parameters and separates them out of the path. - - This uses one of the many defined patterns on the options class. But, - it defaults to a no-op if there are no defined patterns. - """ - # Iterate through the available patterns. - for resource, pattern in cls.meta.patterns: - # Attempt to match the path. - match = re.match(pattern, path) - if match is not None: - # Found something. - return resource, match.groupdict(), match.string[match.end():] - - # No patterns at all; return unsuccessful. - return None if not cls.meta.patterns else False - - @classmethod - def traverse(cls, request, params=None): - """Traverses down the path and determines the accessed resource. - - This makes use of the patterns array to implement simple traversal. - This defaults to a no-op if there are no defined patterns. - """ - # Attempt to parse the path using a pattern. - result = cls.parse(request.path) - if result is None: - # No parsing was requested; no-op. - return cls, {} - - elif not result: - # Parsing failed; raise 404. - raise http.exceptions.NotFound() - - # Partition out the result. - resource, data, rest = result - - if params: - # Append params to data. - data.update(params) - - if resource is None: - # No traversal; return parameters. - return cls, data - - # Modify the path appropriately. - if data.get('path') is not None: - request.path = data.pop('path') - - elif rest is not None: - request.path = rest - - # Send us through traversal again. - result = resource.traverse(request, params=data) - return result - - @classmethod - def stream(cls, response, sequence): - """ - Helper method used in conjunction with the view handler to - stream responses to the client. - """ - # Construct the iterator and run the sequence once in order - # to capture any headers and status codes set. - iterator = iter(sequence) - data = {'chunk': next(iterator)} - response.streaming = True - - def streamer(): - # Iterate through the iterator and yield its content - while True: - if response.asynchronous: - # Yield our current chunk. - yield data['chunk'] - - else: - # Write the chunk to the response - response.send(data['chunk']) - - # Yield its body - yield response.body - - # Unset the body. - response.body = None - - try: - # Get the next chunk. - data['chunk'] = next(iterator) - - except StopIteration: - # Get out of the loop. - break - - if not response.asynchronous: - # Close the response. - response.close() - - # Return the streaming function. - return streamer() - - @utils.boundmethod - def deserialize(self, request=None, text=None, format=None): - """Deserializes the text using a determined deserializer. - - @param[in] request - The request object to pull information from; normally used to - determine the deserialization format (when `format` is - not provided). - - @param[in] text - The text to be deserialized. Can be left blank and the - request will be read. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - - @returns - A tuple of the deserialized data and an instance of the - deserializer used. - """ - if isinstance(self, Resource): - if not request: - # Ensure we have a response object. - request = self._request - - Deserializer = None - if format: - # An explicit format was given; do not attempt to auto-detect - # a deserializer. - Deserializer = self.meta.deserializers[format] - - if not Deserializer: - # Determine an appropriate deserializer to use by - # introspecting the request object and looking at - # the `Content-Type` header. - media_ranges = request.get('Content-Type') - if media_ranges: - # Parse the media ranges and determine the deserializer - # that is the closest match. - media_types = six.iterkeys(self._deserializer_map) - media_type = mimeparse.best_match(media_types, media_ranges) - if media_type: - format = self._deserializer_map[media_type] - Deserializer = self.meta.deserializers[format] - - else: - # Client didn't provide a content-type; we're supposed - # to auto-detect. - # TODO: Implement this. - pass - - if Deserializer: - try: - # Attempt to deserialize the data using the determined - # deserializer. - deserializer = Deserializer() - data = deserializer.deserialize(request=request, text=text) - return data, deserializer - - except ValueError: - # Failed to deserialize the data. - pass - - # Failed to determine a deserializer; or failed to deserialize. - raise http.exceptions.UnsupportedMediaType() - - @utils.boundmethod - def serialize(self, data, response=None, request=None, format=None): - """Serializes the data using a determined serializer. - - @param[in] data - The data to be serialized. - - @param[in] response - The response object to serialize the data to. - If this method is invoked as an instance method, the response - object can be omitted and it will be taken from the instance. - - @param[in] request - The request object to pull information from; normally used to - determine the serialization format (when `format` is not provided). - May be used by some serializers as well to pull additional headers. - If this method is invoked as an instance method, the request - object can be omitted and it will be taken from the instance. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - - @returns - A tuple of the serialized text and an instance of the - serializer used. - """ - if isinstance(self, Resource): - if not request: - # Ensure we have a response object. - request = self._request - - Serializer = None - if format: - # An explicit format was given; do not attempt to auto-detect - # a serializer. - Serializer = self.meta.serializers[format] - - if not Serializer: - # Determine an appropriate serializer to use by - # introspecting the request object and looking at the `Accept` - # header. - media_ranges = (request.get('Accept') or '*/*').strip() - if not media_ranges: - # Default the media ranges to */* - media_ranges = '*/*' - - if media_ranges != '*/*': - # Parse the media ranges and determine the serializer - # that is the closest match. - media_types = six.iterkeys(self._serializer_map) - media_type = mimeparse.best_match(media_types, media_ranges) - if media_type: - format = self._serializer_map[media_type] - Serializer = self.meta.serializers[format] - - else: - # Client indicated no preference; use the default. - default = self.meta.default_serializer - Serializer = self.meta.serializers[default] - - if Serializer: - try: - # Attempt to serialize the data using the determined - # serializer. - serializer = Serializer(request, response) - return serializer.serialize(data), serializer - - except ValueError: - # Failed to serialize the data. - pass - - # Either failed to determine a serializer or failed to serialize - # the data; construct a list of available and valid encoders. - available = {} - for name in self.meta.allowed_serializers: - Serializer = self.meta.serializers[name] - instance = Serializer(request, None) - if instance.can_serialize(data): - available[name] = Serializer.media_types[0] - - # Raise a Not Acceptable exception. - raise http.exceptions.NotAcceptable(available) - - @classmethod - def _process_cross_domain_request(cls, request, response): - """Facilitate Cross-Origin Requests (CORs). - """ - - # Step 1 - # Check for Origin header. - origin = request.get('Origin') - if not origin: - return - - # Step 2 - # Check if the origin is in the list of allowed origins. - if not (origin in cls.meta.http_allowed_origins or - '*' == cls.meta.http_allowed_origins): - return - - # Step 3 - # Try to parse the Request-Method header if it exists. - method = request.get('Access-Control-Request-Method') - if method and method not in cls.meta.http_allowed_methods: - return - - # Step 4 - # Try to parse the Request-Header header if it exists. - headers = request.get('Access-Control-Request-Headers', ()) - if headers: - headers = [h.strip() for h in headers.split(',')] - - # Step 5 - # Check if the headers are allowed on this resource. - allowed_headers = [h.lower() for h in cls.meta.http_allowed_headers] - if any(h.lower() not in allowed_headers for h in headers): - return - - # Step 6 - # Always add the origin. - response['Access-Control-Allow-Origin'] = origin - - # TODO: Check if we can provide credentials. - response['Access-Control-Allow-Credentials'] = 'true' - - # Step 7 - # TODO: Optionally add Max-Age header. - - # Step 8 - # Add the allowed methods. - allowed_methods = ', '.join(cls.meta.http_allowed_methods) - response['Access-Control-Allow-Methods'] = allowed_methods - - # Step 9 - # Add any allowed headers. - allowed_headers = ', '.join(cls.meta.http_allowed_headers) - if allowed_headers: - response['Access-Control-Allow-Headers'] = allowed_headers - - # Step 10 - # Add any exposed headers. - exposed_headers = ', '.join(cls.meta.http_exposed_headers) - if exposed_headers: - response['Access-Control-Expose-Headers'] = exposed_headers - - def __init__(self, request, response): - # Store the request and response objects on self. - self.request = request - self.response = response - - def dispatch(self, request, response): - """Entry-point of the dispatch cycle for this resource. - - Performs common work such as authentication, decoding, etc. before - handing complete control of the result to a function with the - same name as the request method. - """ - # Assert authentication and attempt to get a valid user object. - self.require_authentication(request) - - # Assert accessibiltiy of the resource in question. - self.require_accessibility(request.user, request.method) - - # Facilitate CORS by applying various headers. - # This must be done on every request. - # TODO: Provide cross_domain configuration that turns this off. - self._process_cross_domain_request(request, response) - - # Route the HTTP/1.1 request to an appropriate method. - return self.route(request, response) - - def require_authentication(self, request): - """Ensure we are authenticated.""" - request.user = user = None - - if request.method == 'OPTIONS': - # Authentication should not be checked on an OPTIONS request. - return - - for auth in self.meta.authentication: - user = auth.authenticate(request) - if user is False: - # Authentication protocol failed to authenticate; - # pass the baton. - continue - - if user is None and not auth.allow_anonymous: - # Authentication protocol determined the user is - # unauthenticated. - auth.unauthenticated() - - # Authentication protocol determined the user is indeed - # authenticated (or not); Store the user for later reference. - request.user = user - return - - if not user and not auth.allow_anonymous: - # No authenticated user found and protocol doesn't allow - # anonymous users. - auth.unauthenticated() - - def require_accessibility(self, user, method): - """Ensure we are allowed to access this resource.""" - if method == 'OPTIONS': - # Authorization should not be checked on an OPTIONS request. - return - - authz = self.meta.authorization - if not authz.is_accessible(user, method, self): - # User is not authorized; raise an appropriate message. - authz.unaccessible() - - def require_http_allowed_method(cls, request): - """Ensure that we're allowed to use this HTTP method.""" - allowed = cls.meta.http_allowed_methods - if request.method not in allowed: - # The specified method is not allowed for the resource - # identified by the request URI. - # RFC 2616 § 10.4.6 — 405 Method Not Allowed - raise http.exceptions.MethodNotAllowed(allowed) - - def route(self, request, response): - """Processes every request. - - Directs control flow to the appropriate HTTP/1.1 method. - """ - # Ensure that we're allowed to use this HTTP method. - self.require_http_allowed_method(request) - - # Retrieve the function corresponding to this HTTP method. - function = getattr(self, request.method.lower(), None) - if function is None: - # Server is not capable of supporting it. - raise http.exceptions.NotImplemented() - - # Delegate to the determined function to process the request. - return function(request, response) - - def options(self, request, response): - """Process an `OPTIONS` request. - - Used to initiate a cross-origin request. All handling specific to - CORS requests is done on every request however this method also - returns a list of available methods. - """ - # Gather a list available HTTP/1.1 methods for this URI. - response['Allowed'] = ', '.join(self.meta.http_allowed_methods) - - # All CORS handling is done for every HTTP/1.1 method. - # No more handling is neccesary; set the response to 200 and return. - response.status = http.client.OK diff --git a/armet/resources/resource/meta.py b/armet/resources/resource/meta.py deleted file mode 100644 index 71514ab..0000000 --- a/armet/resources/resource/meta.py +++ /dev/null @@ -1,192 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import armet -from collections import OrderedDict -from armet import utils -from . import options - - -#! Map of connector class objects in their connector modules. -CONNECTORS = OrderedDict([ - ('http', { - 'resource': ('{}.resources', 'Resource', 'resource.base',), - 'options': ('{}.resources', 'ResourceOptions',) - }), - ('model', { - 'resource': ('{}.resources', 'ModelResource', 'model.base',), - 'options': ('{}.resources', 'ModelResourceOptions',) - }) -]) - - -#! Set of connector classes. -connector_set = set() - - -def apply_connectors(meta, name, bases, metadata, base_meta): - - # Apply the declared connectors. What this entails is inserting the - # connector base classes at the correct location in the base class list. - - # The correct location would be directly before one of the base resource - # classes. - - # What is not implemented (and will not be) is replacing existing - # connectors. You may have an inheritance chain of as many "abstract" - # resources as warranted but as soon as a connector is supplied on a - # non-abstract resource then that connector is "locked-in" so-to-speak. - - # Order the connectors found in meta by the order declared above. - connectors = OrderedDict() - for key in reversed(CONNECTORS): - if key in meta.connectors: - connectors[key] = meta.connectors[key] - - meta.connectors = connectors - - # Iterate through the declared connectors. - cmap = CONNECTORS - new_bases = list(bases) - offset = 0 - for key, ref in six.iteritems(meta.connectors): - - try: - # Resolve an optional options class that may be provided by - # the connector. - options = utils.import_module(cmap[key]['options'][0].format(ref)) - options = getattr(options, cmap[key]['options'][1]) - options_instance = options(metadata, name, base_meta) - meta.__dict__.update(**options_instance.__dict__) - - except (AttributeError, KeyError): - pass - - try: - # Resolve the connector itself. - module = utils.import_module(cmap[key]['resource'][0].format(ref)) - class_ = getattr(module, cmap[key]['resource'][1], None) - - # Ensure the connector has not already been applied. - applied = False - for base in bases: - if issubclass(base, class_): - applied = True - - if applied: - continue - - # Find where to insert this connector. - module_key = 'armet.resources.%s' % cmap[key]['resource'][2] - module = utils.import_module(module_key) - armet_resource = getattr(module, cmap[key]['resource'][1]) - for index, base in enumerate(bases): - if issubclass(base, armet_resource): - new_bases.insert(index + offset, class_) - offset += 1 - break - - except (AttributeError, KeyError): - pass - - return tuple(new_bases) - - -class ResourceBase(type): - - #! Options class to use to expand options. - options = options.ResourceOptions - - #! Connectors to instantiate and mixin to the inheritance. - connectors = ['http'] - - @classmethod - def _is_resource(cls, name, bases): - if name == 'NewBase': - # This is a six contrivance; not a real class. - return False - - if name.startswith('armet.connector:'): - # This is special mixed connector class; not something - # to run the metaclass over. - return False - - for base in bases: - if base.__name__ == 'NewBase': - # This is a six contrivance; move along. - continue - - if isinstance(base, cls): - # This is some sort of derived resource; good. - return True - - # This is not derived at all from Resource (eg. is base). - return False - - @classmethod - def _gather_metadata(cls, metadata, bases): - for base in bases: - if isinstance(base, cls) and hasattr(base, 'Meta'): - # Append metadata. - metadata.append(getattr(base, 'Meta')) - - # Recurse. - cls._gather_metadata(metadata, base.__bases__) - - def __new__(cls, name, bases, attrs): - if not cls._is_resource(name, bases): - # This is not an actual resource. - return super(ResourceBase, cls).__new__(cls, name, bases, attrs) - - # Gather the attributes of all options classes. - # Start with the base configuration. - metadata = armet.use().copy() - values = lambda x: {n: getattr(x, n) for n in dir(x)} - - # Expand the options class with the gathered metadata. - base_meta = [] - cls._gather_metadata(base_meta, bases) - - # Apply the configuration from each class in the chain. - for meta in base_meta: - metadata.update(**values(meta)) - - cur_meta = {} - if attrs.get('Meta'): - # Apply the configuration from the current class. - cur_meta = values(attrs['Meta']) - metadata.update(**cur_meta) - - # Gather and construct the options object. - meta = attrs['meta'] = cls.options(metadata, name, cur_meta, base_meta) - - # Apply the found connectors. - bases = apply_connectors(meta, name, bases, metadata, base_meta) - - # Construct the class object. - self = super(ResourceBase, cls).__new__(cls, name, bases, attrs) - - # Generate a serializer map that maps media ranges to serializer - # names. - self._serializer_map = smap = {} - for key in self.meta.allowed_serializers: - serializer = self.meta.serializers[key] - for media_type in serializer.media_types: - smap[media_type] = key - - # Generate a deserializer map that maps media ranges to deserializer - # names. - self._deserializer_map = dmap = {} - for key in self.meta.allowed_deserializers: - deserializer = self.meta.deserializers[key] - for media_type in deserializer.media_types: - dmap[media_type] = key - - # Filter the available connectors according to the - # metaclass restriction set. - for key in list(meta.connectors.keys()): - if key not in cls.connectors: - del meta.connectors[key] - - # Return the constructed instance. - return self diff --git a/armet/resources/resource/options.py b/armet/resources/resource/options.py deleted file mode 100644 index 528e662..0000000 --- a/armet/resources/resource/options.py +++ /dev/null @@ -1,356 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import re -import six -from importlib import import_module -from armet import utils, authentication, authorization -from armet.exceptions import ImproperlyConfigured -from armet import connectors as included_connectors - - -def _merge(options, name, bases, default=None): - """Merges a named option collection.""" - result = None - for base in bases: - if base is None: - continue - - value = getattr(base, name, None) - if value is None: - continue - - result = utils.cons(result, value) - - value = options.get(name) - if value is not None: - result = utils.cons(result, value) - - return result or default - - -class ResourceOptions(object): - - def __init__(self, meta, name, data, bases): - """ - Initializes the options object and defaults configuration not - specified. - - @param[in] meta - Dictionary of the merged meta attributes. - - @param[in] name - Name of the resource class this is being instantiataed for. - """ - #! Whether to allow display of debugging information - #! to the client. - self.debug = meta.get('debug') - if self.debug is None: - self.debug = False - - #! Whether to not actualize a resource from the described class. - #! Abstract resources are meant as generic base classes. - #! - #! @note - #! Abstract only counts if it is directly set on the resource. - self.abstract = data.get('abstract') - - #! Name of the resource to use in URIs; defaults to the dasherized - #! version of the camel cased class name (eg. SomethingHere becomes - #! something-here). The defaulted version also strips a trailing - #! Resource from the name (eg. SomethingHereResource still becomes - #! something-here). - self.name = meta.get('name') - if self.name is None: - # Generate a name for the resource if one is not provided. - # PascalCaseThis => pascal-case-this - dashed = utils.dasherize(name).strip() - if dashed: - # Strip off a trailing Resource as well. - self.name = re.sub(r'-resource$', '', dashed) - - else: - # Something went wrong; just use the class name - self.name = name - - elif callable(self.name): - # If name is callable; pass in the class name and use what - # we got back. - self.name = self.name(name) - - #! True if the resource is expected to operate asynchronously. - #! - #! The side-effect of setting this to True is that returning from - #! `dispatch` (and by extension `get`, `post`, etc.) does not - #! terminate the connection. You must invoke `response.close()` to - #! terminate the connection. - self.asynchronous = meta.get('asynchronous', False) - - #! Connectors to use to connect to the environment. - #! - #! This is a dictionary that maps hooks (keys) to the connector to use - #! for the hook. - #! - #! There is only 1 hook available currently, 'http', and it is - #! required. - #! - #! The available connectors are as follows: - #! - http: - #! > django - #! > flask - #! - #! They may be used as follows: - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! connectors = {'http': 'django'} - #! @endcode - #! - #! Connectors may also be specified as full paths to the connector - #! module (if a connector is located somewhere other than - #! armet.connectors) as follows: - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! connectors = {'http': 'some.other.place'} - #! @endcode - self.connectors = connectors = _merge(meta, 'connectors', bases, {}) - - if not connectors.get('http') and not self.abstract: - raise ImproperlyConfigured('No valid HTTP connector was detected.') - - # Pull out the connectors and convert them into module references. - for key in connectors: - connector = connectors[key] - if isinstance(connector, six.string_types): - if connector in getattr(included_connectors, key): - # Included shortname; prepend base. - connectors[key] = 'armet.connectors.{}'.format(connector) - - #! Additional options to handle and merge into the meta object - #! at the class object level. - #! - #! This should be a simple set/list/tuple of options. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! options = {'color', 'plant'} - #! - #! @endcode - self.options = options = _merge(meta, 'options', bases, {}) - for name in options: - # Pull out each option and stick it on the meta. - setattr(self, name, meta.get(name)) - - #! Regular-expression patterns to apply to the request path - #! and pull arguments and traversal out of it. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! patterns = [ - #! # Match nothing after the resource name. - #! r'^$', - #! - #! # Match a slug after the resource name. - #! r'^/(?P[^/]+)/?$', - #! ] - #! - #! @endcode - #! - #! Named parameters get auto-attached to `self` on the resource - #! instance. - #! - #! This may be a list of lists/tuples as well to indicate simple - #! traversal. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! patterns = [ - #! # Match nothing and don't traverse. - #! (None, r'^$'), - #! - #! # Match the word 'user' and traverse. - #! # The remainder of the path is taken or - #! # the named gruop, "path" (if the whole path was - #! # matched) or the last-matched group (if none are - #! # named "path") - #! (UserResource, r'^/user'), - #! ] - #! - #! @endcode - self.patterns = meta.get('patterns', []) - for index, pattern in enumerate(self.patterns): - # Coerce simple form. - if isinstance(pattern, six.string_types): - pattern = (None, pattern) - - # Compile the expression. - self.patterns[index] = (pattern[0], re.compile(pattern[1])) - - #! Trailing slash handling. - #! The value indicates which URI is the canonical URI and the - #! alternative URI is then made to redirect (with a 301) to the - #! canonical URI. - self.trailing_slash = meta.get('trailing_slash', True) - - #! List of allowed HTTP methods. - #! If not provided and allowed_operations was provided instead - #! the operations are appropriately mapped; else, the default - #! configuration is provided. - self.http_allowed_methods = meta.get('http_allowed_methods') - if self.http_allowed_methods is None: - self.http_allowed_methods = ( - 'HEAD', - 'OPTIONS', - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'LINK', - 'UNLINK' - ) - - #! List of allowed HTTP headers. - #! This is used only to request or prevent CORS requests. - self.http_allowed_headers = meta.get('http_allowed_headers') - if self.http_allowed_headers is None: - self.http_allowed_headers = ( - 'Content-Type', - 'Authorization', - 'Accept', - 'Origin' - ) - - #! List of exposed HTTP headers. - #! This is used only to show headers to the client. - self.http_exposed_headers = meta.get('http_exposed_headers') - if self.http_exposed_headers is None: - self.http_exposed_headers = ( - 'Content-Type', - 'Authorization', - 'Accept', - 'Origin' - ) - - #! List of allowed HTTP origins. - #! This is used to request or prevent CORS requests. - #! No CORS requests will be allowed, at-all, unless this - #! property is set. - #! NOTE: This can be set to '*' to indicate any origin. - self.http_allowed_origins = meta.get('http_allowed_origins') - if self.http_allowed_origins is None: - self.http_allowed_origins = () - - #! Whether to use legacy redirects or not to inform the - #! client the resource is available elsewhere. Legacy redirects - #! require a combination of 301 and 307 in which 307 is not cacheable. - #! Modern redirecting uses 308 and is in effect 307 with cacheing. - #! Unfortunately unknown 3xx codes are treated as - #! a 300 (Multiple choices) in which the user is supposed to chose - #! the alternate link so the client is not supposed to auto follow - #! redirects. Ensure all supported clients understand 308 before - #! turning off legacy redirecting. - #! As of 19 March 2013 only Firefox supports it since a year ago. - self.legacy_redirect = meta.get('legacy_redirect', True) - - #! Mapping of serializers known by this resource. - #! Values may either be a string reference to the serializer type - #! or an serializer class object. - self.serializers = serializers = meta.get('serializers') - if not serializers: - self.serializers = { - 'json': 'armet.serializers.JSONSerializer', - 'url': 'armet.serializers.URLSerializer' - } - - # Expand the serializer name references. - for name, serializer in six.iteritems(self.serializers): - if isinstance(serializer, six.string_types): - segments = serializer.split('.') - module = '.'.join(segments[:-1]) - module = import_module(module) - self.serializers[name] = getattr(module, segments[-1]) - - #! List of allowed serializers of the understood serializers. - self.allowed_serializers = meta.get('allowed_serializers') - if not self.allowed_serializers: - self.allowed_serializers = tuple(self.serializers.keys()) - - # Check to ensure that all allowed serializers are - # understood serializers. - for name in self.allowed_serializers: - if name not in self.serializers: - raise ImproperlyConfigured( - 'The allowed serializer, {}, is not one of the ' - 'understood serializers'.format(name)) - - #! Name of the default serializer of the list of - #! understood serializers. - self.default_serializer = meta.get('default_serializer') - if not self.default_serializer: - if 'json' in self.allowed_serializers: - self.default_serializer = 'json' - - else: - self.default_serializer = self.allowed_serializers[0] - - if self.default_serializer not in self.allowed_serializers: - raise ImproperlyConfigured( - 'The chosen default serializer, {}, is not one of the ' - 'allowed serializers'.format(self.default_serializer)) - - #! Mapping of deserializers known by this resource. - #! Values may either be a string reference to the deserializer type - #! or an deserializer class object. - self.deserializers = deserializers = meta.get('deserializers') - if not deserializers: - self.deserializers = { - 'json': 'armet.deserializers.JSONDeserializer', - 'url': 'armet.deserializers.URLDeserializer' - } - - # Expand the deserializer name references. - for name, deserializer in six.iteritems(self.deserializers): - if isinstance(deserializer, six.string_types): - segments = deserializer.split('.') - module = '.'.join(segments[:-1]) - module = import_module(module) - self.deserializers[name] = getattr(module, segments[-1]) - - #! List of allowed deserializers of the understood deserializers. - self.allowed_deserializers = meta.get('allowed_deserializers') - if not self.allowed_deserializers: - self.allowed_deserializers = tuple(self.deserializers.keys()) - - # Check to ensure that all allowed deserializers are - # understood deserializers. - for name in self.allowed_deserializers: - if name not in self.deserializers: - raise ImproperlyConfigured( - 'The allowed deserializer, {}, is not one of the ' - 'understood deserializers'.format(name)) - - #! List of authentication protocols to attempt in sequence - #! to determine the authenticated user. - self.authentication = meta.get('authentication') - if self.authentication is None: - # Default to a single instance of pass-through authentication. - self.authentication = (authentication.Authentication(),) - - #! The authorization protocol to attempt to use - #! to determine the if the user can access or is otherwise - #! authorized. - self.authorization = meta.get('authorization') - if self.authorization is None: - # Default is the pass-through authorization. - self.authorization = authorization.Authorization() diff --git a/armet/serializers/__init__.py b/armet/serializers/__init__.py deleted file mode 100644 index c290dc6..0000000 --- a/armet/serializers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from .base import Serializer -from .json import JSONSerializer -from .url import URLSerializer - -__all__ = [ - 'Serializer', - 'JSONSerializer', - 'URLSerializer' -] diff --git a/armet/serializers/base.py b/armet/serializers/base.py deleted file mode 100644 index 1c0a68f..0000000 --- a/armet/serializers/base.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class Serializer(object): - - #! Applicable media types for this serializer. - media_types = () - - def __init__(self, request=None, response=None): - #! The request and response objects to use. - self.request = request - self.response = response - - def can_serialize(self, data=None): - """Tests this serializer to see if it can serialize.""" - try: - # Attet to serialize the object. - self.serialize(data) - - # The serialization process is assumed to have succeed. - return True - - except ValueError: - # The object was of an unsupported type. - return False - - def serialize(self, data=None): - """ - Transforms the object into an acceptable format for transmission. - - @throws ValueError - To indicate this serializer does not support the encoding of the - specified object. - """ - if data is not None and self.response is not None: - # Set the content type. - self.response['Content-Type'] = self.media_types[0] - - # Write the encoded and prepared data to the response. - self.response.write(data) - - # Return the serialized data. - # This has normally been transformed by a base class. - return data diff --git a/armet/serializers/json.py b/armet/serializers/json.py deleted file mode 100644 index ba327f7..0000000 --- a/armet/serializers/json.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import ujson as json -from collections import Iterable, Sequence, Mapping -from .base import Serializer -from armet import media_types - - -class JSONSerializer(Serializer): - - media_types = media_types.JSON - - def serialize(self, obj=None): - # If we have nothing; serialize as an empty object. - if obj is None: - return '{}' - - # Ensure generators are evaluated. - if (isinstance(obj, Iterable) - and not isinstance(obj, Sequence) - and not isinstance(obj, Mapping)): - obj = list(obj) - - # Ensure it is atleast wrapped in an array. - if isinstance(obj, six.string_types) or not isinstance(obj, Iterable): - obj = [obj] - - # Serialize the resultant text. - text = json.dumps(obj, ensure_ascii=False) - - # Return us to the base to enclose it inside of a response object. - return super(JSONSerializer, self).serialize(text) diff --git a/armet/serializers/url.py b/armet/serializers/url.py deleted file mode 100644 index 47a7e77..0000000 --- a/armet/serializers/url.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Serializer -from armet import media_types - -# Import urlencode -if six.PY3: - from urllib.parse import urlencode - -else: - from urllib import urlencode - - -class URLSerializer(Serializer): - - media_types = media_types.URL - - def serialize(self, obj=None): - # If we have nothing; serialize as an empty object. - if obj is None: - return '' - - try: - # Attempt to serialize the incoming object using the URL encoder. - return super(URLSerializer, self).serialize(urlencode(obj, True)) - - except TypeError: - # Raise up our hands; we cannot serialize this. - raise ValueError diff --git a/armet/test.py b/armet/test.py deleted file mode 100644 index 71a04b3..0000000 --- a/armet/test.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import httplib2 -import unittest -import socket -import errno -import json -import base64 -import six -from . import http - -__all__ = [ - 'http', - 'is_available', - 'skipUnlessAvailable', - 'Client', - 'ResourceTestCase' -] - - -def is_available(host='localhost', port=5000): - try: - httplib2.Http().request('http://{}:{}/'.format(host, port)) - return True - - except socket.error as ex: - return ex.errno != errno.ECONNREFUSED - - -def skipUnlessAvailable(host='localhost', port=5000): - if not is_available(host, port): - raise unittest.SkipTest( - 'server is not available at {}:{}'.format(host, port)) - - -class Client: - - def __init__(self, host='localhost', port=5000): - # Set the default host and port. - self.host = host - self.port = port - - # Setup the HTTP/1.1 connection. - self.connection = httplib2.Http() - self.connection.follow_redirects = False - - def request(self, path='/', method='GET', body='', headers=None, - url=None, username=None, password=None): - if url is None: - # Construct the URI. - url = 'http://{}:{}{}'.format(self.host, self.port, path) - - # Default headers. - if headers is None: - headers = {} - - # Construct authorization if needed. - if username or password: - creds = '{}:{}'.format(username or '', password or '') - creds = base64.b64encode(creds.encode('utf8')) - headers['authorization'] = 'basic ' + creds.decode('utf8') - - # Serialize the body if neccessary. - # TODO: Support more than JSON. - if not isinstance(body, six.string_types): - body = json.dumps(body) - headers['content-type'] = 'application/json' - - # Perform the response. Remember this does not go anywhere; it is - # caught by wsgi-intercept and mocked into Armet. - return self.connection.request( - url, method, - body=body, - headers=headers) - - def options(self, *args, **kwargs): - kwargs.setdefault('method', 'OPTIONS') - return self.request(*args, **kwargs) - - def head(self, *args, **kwargs): - kwargs.setdefault('method', 'HEAD') - return self.request(*args, **kwargs) - - def get(self, *args, **kwargs): - return self.request(*args, **kwargs) - - def post(self, *args, **kwargs): - kwargs.setdefault('method', 'POST') - return self.request(*args, **kwargs) - - def put(self, *args, **kwargs): - kwargs.setdefault('method', 'PUT') - return self.request(*args, **kwargs) - - def delete(self, *args, **kwargs): - kwargs.setdefault('method', 'DELETE') - return self.request(*args, **kwargs) - - -class ResourceTestCase(unittest.TestCase): - - host = 'localhost' - - port = 5000 - - def setUp(self): - # Skip this test module unless the server is available. - skipUnlessAvailable(self.host, self.port) - - # Initialize the test client. - self.client = Client(self.host, self.port) diff --git a/armet/utils/__init__.py b/armet/utils/__init__.py deleted file mode 100644 index d4b0dea..0000000 --- a/armet/utils/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .decorators import classproperty, boundmethod -from .functional import cons, compose -from .string import dasherize -from .package import import_module - -__all__ = [ - 'classproperty', - 'boundmethod', - 'cons', - 'compose', - 'import_module', - 'dasherize', -] diff --git a/armet/utils/decorators.py b/armet/utils/decorators.py deleted file mode 100644 index 6b49441..0000000 --- a/armet/utils/decorators.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class classproperty(object): - """Declares a read-only `property` that acts on the class object. - """ - - def __init__(self, getter): - self.getter = getter - - def __get__(self, obj, cls): - return self.getter(cls) - - -class boundmethod(object): - """ - Declares a method that can be invoked as both an instance method - and a class method. - """ - - def __init__(self, method): - self.method = method - - def __get__(self, obj, cls): - self.obj = obj if obj else cls - return self - - def __call__(self, *args, **kwargs): - return self.method(self.obj, *args, **kwargs) diff --git a/armet/utils/functional.py b/armet/utils/functional.py deleted file mode 100644 index 18a11a3..0000000 --- a/armet/utils/functional.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import collections - - -def cons(collection, value): - """Extends a collection with a value.""" - if isinstance(value, collections.Mapping): - if collection is None: - collection = {} - collection.update(**value) - - elif isinstance(value, six.string_types): - if collection is None: - collection = [] - collection.append(value) - - elif isinstance(value, collections.Iterable): - if collection is None: - collection = [] - collection.extend(value) - - else: - if collection is None: - collection = [] - collection.append(value) - - return collection - - -def compose(*functions): - - def composed(x): - for func in functions: - x = func(x) - - return x - - return composed diff --git a/armet/utils/package.py b/armet/utils/package.py deleted file mode 100644 index d077aee..0000000 --- a/armet/utils/package.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import print_function, unicode_literals, division -import importlib - - -def import_module(name): - """Attempt to import a module; returns None if unsuccessful.""" - try: - return importlib.import_module(name) - - except ImportError: - return None diff --git a/armet/utils/string.py b/armet/utils/string.py deleted file mode 100644 index a46a9d0..0000000 --- a/armet/utils/string.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import re - - -def dasherize(value): - """Dasherizes the passed value.""" - value = value.strip() - value = re.sub(r'([A-Z])', r'-\1', value) - value = re.sub(r'[-_\s]+', r'-', value) - value = re.sub(r'^-', r'', value) - value = value.lower() - return value diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index eb9b2be..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = -s -rx diff --git a/setup.py b/setup.py index 6b6f48c..937f870 100644 --- a/setup.py +++ b/setup.py @@ -1,58 +1,8 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- from setuptools import setup, find_packages -import sys -import platform from imp import load_source -# Required test dependencies. -test_dependencies = [ - # Test runner. - 'pytest', - - # Ensure PEP8 conformance. - 'pytest-pep8', - - # Ensure test coverage. - 'pytest-cov', - - # Benchmarking. - 'pytest-bench', - - # Installs a WSGI application that intercepts requests made to a hostname - # and port combination for testing. - 'wsgi_intercept >= 0.6.0', - - # HTTP request abstraction layer over httplib. - 'httplib2', - - # The Web framework for perfectionists with deadlines. - 'django', - - # A microframework based on Werkzeug, Jinja2 and good intentions. - 'flask', - - # Bottle is a fast and simple micro-framework for small web applications. - 'bottle', - - # SQLAlchemy is the Python SQL toolkit and Object Relational Mapper - # that gives application developers the full power and flexibility of SQL. - 'sqlalchemy', -] - - -if sys.version_info[0] == 2: - if platform.python_implementation() != 'PyPy': - # Test dependencies that don't work in PyPy. - test_dependencies += [ - # gevent is a coroutine-based Python networking library that uses - # greenlet to provide a high-level synchronous API on top of the - # libevent event loop. - 'gevent', - ] - - setup( name='armet', version=load_source('', 'armet/_version.py').__version__, @@ -74,11 +24,7 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Database', - 'Topic :: Internet', 'Topic :: Software Development', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -86,16 +32,5 @@ url='http://github.com/armet/python-armet', packages=find_packages('.'), install_requires=[ - # Python 2 and 3 normalization layer - 'six', - - # For parsing accept and content-type headers - 'python-mimeparse', - - # Ultra fast JSON encoder and decoder for Python. - 'ujson' ], - extras_require={ - 'test': test_dependencies - } ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/connectors/__init__.py b/tests/connectors/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/connectors/base.py b/tests/connectors/base.py deleted file mode 100644 index ad8c61a..0000000 --- a/tests/connectors/base.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -from importlib import import_module -from wsgi_intercept.httplib2_intercept import install -import pytest -import armet -from armet import test - - -class BaseResourceTest(object): - - #! Host at which the intercept hook is installed. - host = 'localhost' - - #! Port at which the intercept hook is installed. - port = 5000 - - @classmethod - def setup_class(cls): - # Initialize the test client. - cls.client = test.Client(cls.host, cls.port) - - @pytest.fixture(autouse=True, scope='class') - def initialize(self, request, connectors): - # Install the WSGI interception layer. - install() - - # Unload django. - for module in list(sys.modules.keys()): - if module and 'django' in module: - del sys.modules[module] - - # Reset and clear all global cache in armet. - from armet import decorators - - decorators._resources = {} - # decorators._handlers = {} - armet.use.config = {} - - # Re-initialize the configuration. - armet.use(connectors=connectors, debug=True) - - prefix = 'tests.connectors.' - callback = None - if 'model' in connectors: - # Initialize the database access layer. - model = import_module(prefix + connectors['model']) - callback = model.model_setup - - # Add the models module so that it can be generically imported. - sys.modules[prefix + 'models'] = model - request.cls.models = model - - # Initialize the http access layer. - http = import_module(prefix + connectors['http']) - http.http_setup(connectors, self.host, self.port, callback=callback) - - # Add a finalizer to teardown the http layer. - request.addfinalizer(lambda: http.http_teardown(self.host, self.port)) diff --git a/tests/connectors/bottle.py b/tests/connectors/bottle.py deleted file mode 100644 index 6daec00..0000000 --- a/tests/connectors/bottle.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import bottle -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install -from .utils import force_import_module - - -def http_setup(connectors, host, port, callback): - # Install the WSGI interception layer. - install() - - # Bottle is pretty straightforward. - # We just need to push an application context. - application = bottle.Bottle(__name__) - application.catchall = False - - # Ensure we're debugging. - bottle.debug(True) - - # Invoke the callback if we got one. - if callback: - callback() - - # Then import the resources; iterate and mount each one. - module = force_import_module('tests.connectors.resources') - for name in module.__all__: - getattr(module, name).mount(r'/api/', application) - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, lambda: application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) diff --git a/tests/connectors/conftest.py b/tests/connectors/conftest.py deleted file mode 100644 index dc644a5..0000000 --- a/tests/connectors/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import connectors - - -def pytest_generate_tests(metafunc): - # Build a collection of connector hashes to represent every possible - # arrangement. - http = connectors.http - model = connectors.model - scenarios = [[{'http': x, 'model': y}] for x in http for y in model] - ids = ['{}:{}'.format(x, y) for x in http for y in model] - - # Parameterize all test classes. - metafunc.parametrize(['connectors'], scenarios, ids=ids, scope="class") diff --git a/tests/connectors/data.json b/tests/connectors/data.json deleted file mode 100644 index e4dab84..0000000 --- a/tests/connectors/data.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 1, "model": "django.poll", "fields": {"available": true, "question": "Are you an innie or an outie?"}}, {"pk": 2, "model": "django.poll", "fields": {"available": false, "question": "Have you ever written a song?"}}, {"pk": 3, "model": "django.poll", "fields": {"available": true, "question": "Can you make change for a dollar right now?"}}, {"pk": 4, "model": "django.poll", "fields": {"available": false, "question": "Have you ever been in the opposite sex's public toilet?"}}, {"pk": 5, "model": "django.poll", "fields": {"available": true, "question": "Have you ever written a poem?"}}, {"pk": 6, "model": "django.poll", "fields": {"available": false, "question": "Do you like catsup on or beside your fries?"}}, {"pk": 7, "model": "django.poll", "fields": {"available": true, "question": "Have you ever been a boy/girl scout?"}}, {"pk": 8, "model": "django.poll", "fields": {"available": false, "question": "Have you ever written a book?"}}, {"pk": 9, "model": "django.poll", "fields": {"question": "Have you ever broken a mirror?"}}, {"pk": 10, "model": "django.poll", "fields": {"question": "Are you superstitious?"}}, {"pk": 11, "model": "django.poll", "fields": {"question": "What is your biggest pet peeve?"}}, {"pk": 12, "model": "django.poll", "fields": {"question": "Do you slurp your drink after it's gone?"}}, {"pk": 13, "model": "django.poll", "fields": {"question": "Have you ever blown bubbles in your milk?"}}, {"pk": 14, "model": "django.poll", "fields": {"question": "Would you rather eat a Big Mac or a Whopper?"}}, {"pk": 15, "model": "django.poll", "fields": {"question": "Have you ever gone skinny-dipping?"}}, {"pk": 16, "model": "django.poll", "fields": {"question": "Would you ever parachute out of a plane?"}}, {"pk": 17, "model": "django.poll", "fields": {"question": "What's the most daring thing you've done?"}}, {"pk": 18, "model": "django.poll", "fields": {"question": "When you are at the grocery store, do you ask for paper or plastic?"}}, {"pk": 19, "model": "django.poll", "fields": {"question": "True or False: You would rather eat steak than pizza."}}, {"pk": 20, "model": "django.poll", "fields": {"question": "Did you have a baby blanket?"}}, {"pk": 21, "model": "django.poll", "fields": {"question": "Have you ever tried to cut your own hair?"}}, {"pk": 22, "model": "django.poll", "fields": {"question": "How did that turn out?"}}, {"pk": 23, "model": "django.poll", "fields": {"question": "Have you ever sleepwalked?"}}, {"pk": 24, "model": "django.poll", "fields": {"question": "Have you ever had a birthday party at McDonalds?"}}, {"pk": 25, "model": "django.poll", "fields": {"question": "Can you flip your eye-lids up?"}}, {"pk": 26, "model": "django.poll", "fields": {"question": "Are you double jointed?"}}, {"pk": 27, "model": "django.poll", "fields": {"question": "If you could be any age, what age would you be?"}}, {"pk": 28, "model": "django.poll", "fields": {"question": "Have you ever gotten gum stuck in your hair?"}}, {"pk": 29, "model": "django.poll", "fields": {"question": "Do you ride roller coasters?"}}, {"pk": 30, "model": "django.poll", "fields": {"question": "What's your favorite carnival ride?"}}, {"pk": 31, "model": "django.poll", "fields": {"question": "What is your dream car?"}}, {"pk": 32, "model": "django.poll", "fields": {"question": "What is your favorite cartoon of all time?"}}, {"pk": 33, "model": "django.poll", "fields": {"question": "Have you ever eaten a dog biscuit?"}}, {"pk": 34, "model": "django.poll", "fields": {"question": "If so, would you eat another one?"}}, {"pk": 35, "model": "django.poll", "fields": {"question": "If you were in a car sinking in a lake, what would you do first?"}}, {"pk": 36, "model": "django.poll", "fields": {"question": "Have you ever ridden in an ambulance?"}}, {"pk": 37, "model": "django.poll", "fields": {"question": "Can you pick something up with your toes?"}}, {"pk": 38, "model": "django.poll", "fields": {"question": "How many remote controls do you have in your house?"}}, {"pk": 39, "model": "django.poll", "fields": {"question": "Have you ever fallen asleep in school?"}}, {"pk": 40, "model": "django.poll", "fields": {"question": "How many times have you flown in an airplane in the last year?"}}, {"pk": 41, "model": "django.poll", "fields": {"question": "How many foreign countries have you visited?"}}, {"pk": 42, "model": "django.poll", "fields": {"question": "If you were out of shape, would you compete in a triathlon if you were somehow guaranteed to win a big, gaudy medal?"}}, {"pk": 43, "model": "django.poll", "fields": {"question": "Would you rather be rich and unhappy, or poor and happy?"}}, {"pk": 44, "model": "django.poll", "fields": {"question": "If you fell into quicksand, would you try to swim or try to float?"}}, {"pk": 45, "model": "django.poll", "fields": {"question": "Do you ask for directions when you are lost?"}}, {"pk": 46, "model": "django.poll", "fields": {"question": "Have you ever held a Mexican jumping bean?"}}, {"pk": 47, "model": "django.poll", "fields": {"question": "Are you more like Cinderella or Alice in Wonderland?"}}, {"pk": 48, "model": "django.poll", "fields": {"question": "Would you rather have an ant farm with no ants or a box of crayons with broken points?"}}, {"pk": 49, "model": "django.poll", "fields": {"question": "Do you prefer light or dark bread?"}}, {"pk": 50, "model": "django.poll", "fields": {"question": "Do you prefer scrambled or fried eggs?"}}, {"pk": 51, "model": "django.poll", "fields": {"question": "Have you ever been in a car that ran out of gas?"}}, {"pk": 52, "model": "django.poll", "fields": {"question": "Do you talk in your sleep?"}}, {"pk": 53, "model": "django.poll", "fields": {"question": "Would you rather shovel snow or mow the lawn?"}}, {"pk": 54, "model": "django.poll", "fields": {"question": "Have you ever played in the rain?"}}, {"pk": 55, "model": "django.poll", "fields": {"question": "Did you make mud pies?"}}, {"pk": 56, "model": "django.poll", "fields": {"question": "Have you ever broken a bone?"}}, {"pk": 57, "model": "django.poll", "fields": {"question": "Would you climb a very high tree to save a kitten?"}}, {"pk": 58, "model": "django.poll", "fields": {"question": "Can you tell the difference between a crocodile and an alligator?"}}, {"pk": 59, "model": "django.poll", "fields": {"question": "Do you drink pepsi or coke?"}}, {"pk": 60, "model": "django.poll", "fields": {"question": "What's your favorite number?"}}, {"pk": 61, "model": "django.poll", "fields": {"question": "If you were a car, would you be an SUV or a sports car?"}}, {"pk": 62, "model": "django.poll", "fields": {"question": "Have you ever accidentally taken something from a hotel?"}}, {"pk": 63, "model": "django.poll", "fields": {"question": "Have you ever slipped in the bathtub?"}}, {"pk": 64, "model": "django.poll", "fields": {"question": "Do you use regular or deodorant soap?"}}, {"pk": 65, "model": "django.poll", "fields": {"question": "Have you ever locked yourself out of the house?"}}, {"pk": 66, "model": "django.poll", "fields": {"question": "Would you rather make your living as a singing cowboy or as one of the Simpsons voices?"}}, {"pk": 67, "model": "django.poll", "fields": {"question": "If you could invite any movie star to your home for dinner, who would it be?"}}, {"pk": 68, "model": "django.poll", "fields": {"question": "Do you need corrective lenses?"}}, {"pk": 69, "model": "django.poll", "fields": {"question": "Would you hang out with / date someone your best friend didn't like?"}}, {"pk": 70, "model": "django.poll", "fields": {"question": "Would you hang out with someone your best friend liked, but you didn't like?"}}, {"pk": 71, "model": "django.poll", "fields": {"question": "Have you ever returned a gift?"}}, {"pk": 72, "model": "django.poll", "fields": {"question": "Would you give someone else a gift that had been given to you?"}}, {"pk": 73, "model": "django.poll", "fields": {"question": "If you could attend an Olympic Event, what would it be?"}}, {"pk": 74, "model": "django.poll", "fields": {"question": "If you could participate in an Olympic Event, what would it be?"}}, {"pk": 75, "model": "django.poll", "fields": {"question": "How many pairs of shoes do you own?"}}, {"pk": 76, "model": "django.poll", "fields": {"question": "If your grandmother gave you a gift that you already have, would you tell her?"}}, {"pk": 77, "model": "django.poll", "fields": {"question": "Do you sing in the car?"}}, {"pk": 78, "model": "django.poll", "fields": {"question": "What is your favorite breed of dog?"}}, {"pk": 79, "model": "django.poll", "fields": {"question": "Would you donate money to feed starving animals in the winter?"}}, {"pk": 80, "model": "django.poll", "fields": {"question": "What is your favorite fruit?"}}, {"pk": 81, "model": "django.poll", "fields": {"question": "What is your least favorite fruit?"}}, {"pk": 82, "model": "django.poll", "fields": {"question": "What kind of fruit have you never had?"}}, {"pk": 83, "model": "django.poll", "fields": {"question": "If you won a $5,000 shopping spree to any store, which store would you pick?"}}, {"pk": 84, "model": "django.poll", "fields": {"question": "What brand sports apparel do you wear the most?"}}, {"pk": 85, "model": "django.poll", "fields": {"question": "Are/were you a good student?"}}, {"pk": 86, "model": "django.poll", "fields": {"question": "Among your friends, who could you arm wrestle and beat?"}}, {"pk": 87, "model": "django.poll", "fields": {"question": "If you had to choose, what branch of the military would you be in?"}}, {"pk": 88, "model": "django.poll", "fields": {"question": "What do you think is your best feature?"}}, {"pk": 89, "model": "django.poll", "fields": {"question": "If you were to win a Grammy, what kind of music would it be for?"}}, {"pk": 90, "model": "django.poll", "fields": {"question": "If you were to win an Osacr, what kind of movie would it be for?"}}, {"pk": 91, "model": "django.poll", "fields": {"question": "What is your favorite season?"}}, {"pk": 92, "model": "django.poll", "fields": {"question": "How many members do you have in your immediate family?"}}, {"pk": 93, "model": "django.poll", "fields": {"question": "Which of the five senses is most important to you?"}}, {"pk": 94, "model": "django.poll", "fields": {"question": "Would you be a more successful painter or singer?"}}, {"pk": 95, "model": "django.poll", "fields": {"question": "How many years will/did you end up going to college?"}}, {"pk": 96, "model": "django.poll", "fields": {"question": "Have you ever had surgery?"}}, {"pk": 97, "model": "django.poll", "fields": {"question": "Would you rather be a professional figure skater or professional football player?"}}, {"pk": 98, "model": "django.poll", "fields": {"question": "What do you like to collect?"}}, {"pk": 99, "model": "django.poll", "fields": {"question": "How many collectibles do you have?"}}, {"pk": 100, "model": "django.poll", "fields": {"question": "What one question would you add to this survey?"}}] diff --git a/tests/connectors/django/__init__.py b/tests/connectors/django/__init__.py deleted file mode 100644 index cab8169..0000000 --- a/tests/connectors/django/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import os -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install - -# Setup the environment variables. -os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.connectors.django.settings' - -from .models import Poll - -__all__ = [ - 'Poll' -] - - -def http_setup(connectors, host, port, callback): - # Setup the environment variables. - os.environ['DJANGO_SETTINGS_MODULE'] = ( - 'tests.connectors.django.settings') - - # Invoke the callback if we got one. - if callback: - callback() - - # Install the WSGI interception layer. - install() - - # Import and grab the django application. - from django.core.wsgi import get_wsgi_application - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, get_wsgi_application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) - - -def model_setup(): - # Setup the environment variables. - os.environ['DJANGO_SETTINGS_MODULE'] = ( - 'tests.connectors.django.settings') - - # Initialize the database and create all models. - from django.db import connections, DEFAULT_DB_ALIAS - connection = connections[DEFAULT_DB_ALIAS] - connection.creation.create_test_db(verbosity=0) - - # Load the data fixture. - from django.core import management - data = os.path.join(os.path.dirname(__file__), '..', 'data.json') - management.call_command('loaddata', data, verbosity=0, interactive=0) diff --git a/tests/connectors/django/models.py b/tests/connectors/django/models.py deleted file mode 100644 index 5c863ea..0000000 --- a/tests/connectors/django/models.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.db import models - - -class Poll(models.Model): - - question = models.CharField(max_length=1024) - - available = models.NullBooleanField() - - votes = models.IntegerField(null=True) diff --git a/tests/connectors/django/settings.py b/tests/connectors/django/settings.py deleted file mode 100644 index 2c2f758..0000000 --- a/tests/connectors/django/settings.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -# from __future__ import absolute_import, unicode_literals, division -from os import path - -DEBUG = True -TEMPLATE_DEBUG = DEBUG - -PROJECT_ROOT = path.abspath(path.dirname(__file__)) - -ADMINS = ( - # ('Your Name', 'your_email@example.com'), -) - -MANAGERS = ADMINS - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', - } -} - -# Turn off auto slash handling. -APPEND_SLASH = False - -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] - -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'America/Chicago' - -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -SITE_ID = 1 - -# URL configuration. -ROOT_URLCONF = 'tests.connectors.django.urls' - -# If you set this to False, Django will make some optimizations so as not -# to load the internationalization machinery. -USE_I18N = True - -# If you set this to False, Django will not format dates, numbers and -# calendars according to the current locale. -USE_L10N = True - -# If you set this to False, Django will not use timezone-aware datetimes. -USE_TZ = True - -# Absolute filesystem path to the directory that will hold user-uploaded files. -# Example: "/var/www/example.com/media/" -MEDIA_ROOT = '' - -# URL that handles the media served from MEDIA_ROOT. Make sure to use a -# trailing slash. -# Examples: "http://example.com/media/", "http://media.example.com/" -MEDIA_URL = '' - -# Absolute path to the directory static files should be collected to. -# Don't put anything in this directory yourself; store your static files -# in apps' "static/" subdirectories and in STATICFILES_DIRS. -# Example: "/var/www/example.com/static/" -STATIC_ROOT = '' - -# URL prefix for static files. -# Example: "http://example.com/static/", "http://static.example.com/" -STATIC_URL = '/static/' - -# Additional locations of static files -STATICFILES_DIRS = ( - # Put strings here, like "/home/html/static" or "C:/www/django/static". - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. -) - -# List of finder classes that know how to find static files in -# various locations. -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -) - -# Make this unique, and don't share it with anybody. -SECRET_KEY = '_lhu2gqrh7j0+9aa_*n-fzerhsar+n$tm1nf+6i+f+$abx#$q@' - -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -) - -MIDDLEWARE_CLASSES = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - # Uncomment the next line for simple clickjacking protection: - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', -) - -TEMPLATE_DIRS = ( - # Put strings here, like "/home/html/django_templates" - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. -) - -INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'tests.connectors.django' -) - -# A sample logging configuration. The only tangible logging -# performed by this configuration is to send an email to -# the site admins on every HTTP 500 error when DEBUG=False. -# See http://docs.djangoproject.com/en/dev/topics/logging for -# more details on how to customize your logging configuration. -LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'simple': { - 'format': '%(levelname)s %(message)s' - }, - }, - 'handlers': { - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, - }, - 'loggers': { - 'armet': { - 'handlers': ['console'], - 'level': 'DEBUG', - }, - } -} diff --git a/tests/connectors/django/urls.py b/tests/connectors/django/urls.py deleted file mode 100644 index 76039eb..0000000 --- a/tests/connectors/django/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.conf.urls import patterns, include, url -from ..utils import force_import_module - - -# Initial URL configuration. -urlpatterns = patterns('') - -# Import the resources; iterate and mount each one. -module = force_import_module('tests.connectors.resources') -for name in module.__all__: - cls = getattr(module, name) - urlpatterns += patterns('', url(r'^api/', include(cls.urls))) diff --git a/tests/connectors/flask.py b/tests/connectors/flask.py deleted file mode 100644 index eb7824c..0000000 --- a/tests/connectors/flask.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import flask -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install -from .utils import force_import_module - - -def http_setup(connectors, host, port, callback): - # Install the WSGI interception layer. - install() - - # Flask is pretty straightforward. - # We just need to push an application context. - application = flask.Flask(__name__) - application.debug = True - - # Invoke the callback if we got one. - if callback: - callback() - - # Then import the resources; iterate and mount each one. - module = force_import_module('tests.connectors.resources') - for name in module.__all__: - getattr(module, name).mount(r'/api/', application) - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, lambda: application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) diff --git a/tests/connectors/resources.py b/tests/connectors/resources.py deleted file mode 100644 index 7031a8f..0000000 --- a/tests/connectors/resources.py +++ /dev/null @@ -1,474 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -import json -import armet -from armet import resources, attributes, exceptions - -# Request the generic models module inserted by the test runner. -models = sys.modules['tests.connectors.models'] - -__all__ = [ - 'SimpleResource', - 'SimpleTrailingResource', - 'PollResource', - 'StreamingResource', - 'AsyncResource', - 'AsyncStreamResource', - 'lightweight', - 'lightweight_streaming', - 'lightweight_async', - 'LeftResource', - 'RightResource', - 'echo', - 'cookie', - 'DirectResource', - 'ModelDirectResource', - 'IndirectResource', - 'ModelIndirectResource', - 'TwiceIndirectResource', - 'ModelTwiceIndirectResource', - 'ThriceIndirectResource', - 'ModelThriceIndirectResource', - 'MixinResource', - 'PollExcludeResource', - 'PollUnreadResource', - 'PollUnwriteResource', - 'PollNoNullResource', - 'PollRequiredResource', - 'PollValidResource', - 'PollNamedResource', - 'DirectConnectorResource', - 'IndirectConnectorResource', - 'TwiceIndirectConnectorResource', - 'ThriceIndirectConnectorResource', - 'DirectModelConnectorResource', - 'IndirectModelConnectorResource', - 'TwiceIndirectModelConnectorResource', - 'ThriceIndirectModelConnectorResource', - 'DirectModelConnectorMixinResource', - 'IndirectModelConnectorMixinResource', - 'TwiceIndirectModelConnectorMixinResource', - 'ThriceIndirectModelConnectorMixinResource', -] - - -class SimpleResource(resources.Resource): - - def get(self, request, response): - # Do nothing and return nothing. - pass - - -class SimpleTrailingResource(resources.Resource): - - class Meta: - trailing_slash = False - - def get(self, request, response): - # Do nothing and return nothing. - pass - - -class StreamingResource(resources.Resource): - - def get(self, request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - - yield 'this\n' - - response.write('where\n') - response.flush() - response.write('whence\n') - - yield - yield 'that\n' - - response.write('why\n') - - yield 'and the other' - - -def spawn(connectors, function): - import gevent - gevent.spawn(function) - - -class AsyncResource(resources.Resource): - - class Meta: - asynchronous = True - - def get(self, request, response): - - def writer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - response.close() - - spawn(self.meta.connectors, writer) - - -class AsyncStreamResource(resources.Resource): - - class Meta: - asynchronous = True - - def get(self, request, response): - - def streamer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('this\n') - response.flush() - - response.write('where\n') - response.flush() - - response.write('whence\n') - response.write('that\n') - response.flush() - - response.write('why\n') - response.write('and the other') - response.close() - - spawn(self.meta.connectors, streamer) - - -@armet.resource(methods='GET') -def lightweight(request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - - -@armet.resource(methods='POST') -def lightweight(request, response): - response.status = 414 - response['Content-Type'] = 'text/plain' - response.write('Hello POST') - - -@armet.resource(methods='GET') -def lightweight_streaming(request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - - yield 'this\n' - - response.write('where\n') - response.flush() - response.write('whence\n') - - yield - yield 'that\n' - - response.write('why\n') - - yield 'and the other' - - -@armet.asynchronous -@armet.resource(methods='GET') -def lightweight_async(request, response): - def writer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - response.close() - - spawn(lightweight_async.meta.connectors, writer) - - -class PollResource(resources.ModelResource): - - class Meta: - model = models.Poll - - slug = 'id' - - id = attributes.IntegerAttribute('id') - - question = attributes.TextAttribute('question') - - available = attributes.BooleanAttribute('available') - - -class LeftResource(resources.Resource): - - class Meta: - http_allowed_methods = ('HEAD', 'GET', 'OPTIONS',) - http_allowed_origins = ('http://127.0.0.1:80',) - http_allowed_headers = ('Content-Type', 'Content-MD5', 'Accept',) - - def get(self): - # Do nothing. - pass - - -class RightResource(resources.Resource): - - class Meta: - http_allowed_methods = ('HEAD', 'GET', 'DELETE', 'OPTIONS',) - http_allowed_origins = ('*',) - http_allowed_headers = ('Content-Type', 'Content-MD5', 'Accept') - - def get(self): - # Do nothing. - pass - - -@armet.resource(methods='POST') -def echo(request, response): - # Read in the given data in the given format. - data = request.read(deserialize=True) - - # Write out the parsed data in the requested format. - response.write(data, serialize=True) - - -@armet.resource(methods='GET') -def cookie(request, response): - response['Content-Type'] = 'text/plain' - response.write(request.cookies['blue'].value) - - -class DirectResource(resources.Resource): - - def route(self, request, response): - return super(DirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'42') - - -class ModelDirectResource(resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - question = attributes.TextAttribute('question') - - def route(self, request, response): - return super(ModelDirectResource, self).route(request, response) - - -class IndirectResource(DirectResource): - - def route(self, request, response): - return super(IndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelIndirectResource(ModelDirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelIndirectResource, self).read() - - -class TwiceIndirectResource(IndirectResource): - - def route(self, request, response): - return super(TwiceIndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelTwiceIndirectResource(ModelIndirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelTwiceIndirectResource, self).read() - - -class ThriceIndirectResource(IndirectResource): - - def route(self, request, response): - return super(ThriceIndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelThriceIndirectResource(ModelIndirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelThriceIndirectResource, self).read() - - -class ExtraStuff(object): - - def dispatch(self, *args): - self.content = b'Hello' - return super(ExtraStuff, self).dispatch(*args) - - -class MixinResource(ExtraStuff, IndirectResource): - - content = b'World' - - def route(self, request, response): - return super(MixinResource, self).route(request, response) - - def get(self, request, response): - response.write(self.content) - - -class PollExcludeResource(PollResource): - - question = attributes.TextAttribute('question', include=False) - - -class PollUnreadResource(PollResource): - - question = attributes.TextAttribute('question', read=False) - - -class PollUnwriteResource(PollResource): - - question = attributes.TextAttribute('question', write=False) - - -class PollNoNullResource(PollResource): - - question = attributes.TextAttribute('question', null=False) - - -class PollRequiredResource(PollResource): - - question = attributes.TextAttribute('question', required=True) - - -class PollNamedResource(PollResource): - - question = attributes.TextAttribute('question', name='superQuestion') - - -class PollValidResource(PollResource): - - votes = attributes.IntegerAttribute('votes') - - def clean_votes(self, value): - assert value > 0, 'Must be greater than 0.' - assert value < 51, 'Must be less than 51.' - return value - - def clean_question(self, value): - errors = [] - - if len(value) <= 15: - errors.append('Must be more than 15 characters.') - - if value.find('?') == -1: - errors.append('Must have at least one question mark.') - - if errors: - raise exceptions.ValidationError(*errors) - - return value - - -class DirectConnectorResource(resources.Resource): - - @classmethod - def view(cls, *args, **kwargs): - cls.status = 205 - return super(DirectConnectorResource, cls).view(*args, **kwargs) - - def get(self, request, response): - response.status = self.status - response.write(b'Hello World\n') - - -class IndirectConnectorResource(DirectConnectorResource): - pass - - -class TwiceIndirectConnectorResource(IndirectConnectorResource): - pass - - -class ThriceIndirectConnectorResource(TwiceIndirectConnectorResource): - pass - - -class DirectModelConnectorResource(resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - def route(self, request, response): - response.status = 205 - return super(DirectModelConnectorResource, self).route( - request, response) - - def get(self, request, response): - response.write(json.dumps(self.read()).encode('utf8')) - - def read(self): - return ['Hello', 'World'] - - -class IndirectModelConnectorResource(DirectModelConnectorResource): - pass - - -class TwiceIndirectModelConnectorResource(IndirectModelConnectorResource): - pass - - -class ThriceIndirectModelConnectorResource( - TwiceIndirectModelConnectorResource): - pass - - -class DirectModelConnectorMixin(object): - - def destroy(self): - self.response.status = 402 - return None - - -class DirectModelConnectorMixinResource( - DirectModelConnectorMixin, resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - def delete(self, request, response): - self.response.status = 405 - self.destroy() - - -class IndirectModelConnectorMixinResource(DirectModelConnectorMixinResource): - pass - - -class TwiceIndirectModelConnectorMixinResource( - IndirectModelConnectorMixinResource): - pass - - -class ThriceIndirectModelConnectorMixinResource( - TwiceIndirectModelConnectorMixinResource): - pass diff --git a/tests/connectors/sqlalchemy.py b/tests/connectors/sqlalchemy.py deleted file mode 100644 index d43eac1..0000000 --- a/tests/connectors/sqlalchemy.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import os -import json -import armet -import sqlalchemy as sa -from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base - -# Instantiate the declarative base. -Base = declarative_base() - -# Instantiate the engine used to access the models. -engine = sa.create_engine('sqlite:///:memory:') - -# Construct the session factory. -Session = orm.sessionmaker(bind=engine) - - -class Poll(Base): - - __tablename__ = 'poll' - - id = sa.Column(sa.Integer, primary_key=True) - - question = sa.Column(sa.String(1024)) - - available = sa.Column(sa.Boolean) - - votes = sa.Column(sa.Integer) - - -def _load_fixture(filename): - """ - Loads the passed fixture into the database following the - django format. - """ - - # Read the binary data into text - with open(filename, 'rb') as stream: - content = stream.read().decode('utf-8') - - # Decode the data as JSON - data = json.loads(content) - - # Instantiate a session. - session = Session() - - # Iterate through the entries to add them one by one. - for item in data: - # Resolve model from the table reference. - table = Base.metadata.tables[item['model'].split('.')[-1]] - - # Add the primary key. - item['fields']['id'] = item['pk'] - - # Add a new row. - session.connection().execute(table.insert().values(**item['fields'])) - - # Commit the session to the database. - session.commit() - - -def model_setup(): - # Initialize the database and create all models. - Base.metadata.drop_all(engine) - Base.metadata.create_all(engine) - - # Load the data fixture. - _load_fixture(os.path.join(os.path.dirname(__file__), 'data.json')) - - # Configure armet and provide the session factory. - armet.use(Session=Session) diff --git a/tests/connectors/test_access.py b/tests/connectors/test_access.py deleted file mode 100644 index 0022f9f..0000000 --- a/tests/connectors/test_access.py +++ /dev/null @@ -1,147 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -from .base import BaseResourceTest -import pytest - -# Shortcut to the skipif marker. -skipif = pytest.mark.skipif - - -class TestResourceAccess(BaseResourceTest): - - def test_simple(self, connectors): - response, _ = self.client.get('/api/simple/') - - assert response.status == http.client.OK - - def test_redirect(self, connectors): - response, _ = self.client.get('/api/simple') - - assert response.status == http.client.MOVED_PERMANENTLY - - uri = 'http://{}:{}/api/simple/'.format(self.host, self.port) - assert response.get('location') == uri - - def test_redirect_trailing(self, connectors): - response, _ = self.client.get('/api/simple-trailing/') - - assert response.status == http.client.MOVED_PERMANENTLY - - uri = 'http://{}:{}/api/simple-trailing'.format(self.host, self.port) - assert response.get('location') == uri - - # NOTE: The below test fails in flask / werkzeug and I don't want - # to add crazy hacks to make it work. - # - # def test_redirect_complex(self, connectors): - # response, _ = self.client.get('/api/simple:hello(x=y)?x=3&y=4') - - # assert response.status == http.client.MOVED_PERMANENTLY - - # uri = 'http://{}:{}/api/simple:hello(x=y)/?x=3&y=4'.format(self.host, - # self.port) - # assert response.get('location') == uri - - def test_not_found(self, connectors): - response, _ = self.client.get('/api/unknown') - - assert response.status == http.client.NOT_FOUND - - response, _ = self.client.get('/api/unknown/') - - assert response.status == http.client.NOT_FOUND - - def test_unknown(self, connectors): - response, _ = self.client.request('/api/simple/', method='APPLE') - - assert response.status == http.client.METHOD_NOT_ALLOWED - - def test_not_allowed(self, connectors): - response, _ = self.client.request('/api/simple/', method='CONNECT') - - assert response.status == http.client.METHOD_NOT_ALLOWED - - def test_not_implemented(self, connectors): - response, _ = self.client.request('/api/simple/', method='PATCH') - - assert response.status == http.client.NOT_IMPLEMENTED - - def test_streaming(self, connectors): - response, content = self.client.request('/api/streaming/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_async(self, connectors): - # response, content = self.client.request('/api/async/') - # data = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert data == 'Hello' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_async_stream(self, connectors): - # response, content = self.client.request('/api/async-stream/') - # content = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert content == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - -class TestResourceLightweight(BaseResourceTest): - - def test_lightweight(self, connectors): - response, content = self.client.get('/api/lightweight/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'Hello' - - def test_lightweight_post(self, connectors): - response, content = self.client.post('/api/lightweight/') - data = content.decode('utf-8') - - assert response.status == 414 - assert response.get('content-type') == 'text/plain' - assert data == 'Hello POST' - - def test_lightweight_streaming(self, connectors): - response, content = self.client.request('/api/lightweight-streaming/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_lightweight_async(self, connectors): - # response, content = self.client.request('/api/lightweight-async/') - # data = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert data == 'Hello' - - -class TestResourceCookie(BaseResourceTest): - - def test_send_and_check(self, connectors): - response, content = self.client.request( - path='/api/cookie/', - headers={'Cookie': 'blue=color'}) - - data = content.decode('utf-8') - - assert response.status == 200 - assert response.get('content-type') == 'text/plain' - assert data == 'color' diff --git a/tests/connectors/test_attributes.py b/tests/connectors/test_attributes.py deleted file mode 100644 index 16b190e..0000000 --- a/tests/connectors/test_attributes.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourceAttributeProperties(BaseResourceTest): - - def test_not_include(self): - response, content = self.client.get('/api/poll-exclude/1/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'id' in data - assert 'question' not in data - - def test_not_include_access(self): - response, content = self.client.get('/api/poll-exclude/1/question/') - - assert response.status == http.client.OK - assert content.decode('utf8') == '["Are you an innie or an outie?"]' - - def test_not_read(self): - response, content = self.client.get('/api/poll-unread/1/question/') - - assert response.status == http.client.FORBIDDEN - - def test_not_include_but_create(self): - data = {'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-exclude/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.CREATED - - data = json.loads(content.decode('utf8')) - - assert 'question' not in data - assert data['id'] == 101 - - response, content = self.client.get('/api/poll/101/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert data['question'] == 'Is anybody really out there?' - - def test_not_include_but_replace(self): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-exclude/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'question' not in data - assert data['id'] == 1 - - response, content = self.client.get('/api/poll/1/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert data['question'] == 'Is anybody really out there?' - - def test_not_write(self): - data = {'id': 123, 'question': 'gr9uer0gn2w'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-unwrite/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Attribute is read-only.'] - - def test_not_write_same(self): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-unwrite/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - def test_no_null(self): - data = {'question': None} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-no-null/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Must not be null.'] - - def test_required(self): - data = {} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-required/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Must be provided.'] - - def test_name(self): - data = {'superQuestion': 'something', 'id': 1} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-named/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'superQuestion' in data - assert data['superQuestion'] == 'something' diff --git a/tests/connectors/test_delete.py b/tests/connectors/test_delete.py deleted file mode 100644 index 037f2e8..0000000 --- a/tests/connectors/test_delete.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -from .base import BaseResourceTest - - -class TestResourceDelete(BaseResourceTest): - - def test_delete_existing(self, connectors): - response, content = self.client.delete( - path='/api/poll/1/', - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.NO_CONTENT diff --git a/tests/connectors/test_get.py b/tests/connectors/test_get.py deleted file mode 100644 index f368a5e..0000000 --- a/tests/connectors/test_get.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -from armet import http -from .base import BaseResourceTest -from pytest import mark - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceGet(BaseResourceTest): - - def test_list(self): - response, content = self.client.request('/api/poll/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 100 - assert data[0]['question'] == 'Are you an innie or an outie?' - assert (data[-1]['question'] == - 'What one question would you add to this survey?') - - def test_get_not_found(self, connectors): - response, _ = self.client.get('/api/poll/101/') - - assert response.status == http.client.NOT_FOUND - - def test_single(self, connectors): - response, content = self.client.request('/api/poll/1/') - data = json.loads(content.decode('utf-8')) - - assert response.status == http.client.OK - assert isinstance(data, dict) - assert data['question'] == 'Are you an innie or an outie?' - - response, content = self.client.request('/api/poll/100/') - data = json.loads(content.decode('utf-8')) - - assert response.status == http.client.OK - assert isinstance(data, dict) - assert (data['question'] == - 'What one question would you add to this survey?') - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceQuery(BaseResourceTest): - - def test_param_eq_one(self, connectors): - response, content = self.client.request('/api/poll/?id=1') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 1 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_eq_same_and(self, connectors): - response, content = self.client.request('/api/poll/?id=1&id=2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 0 - - def test_param_eq_same_or(self, connectors): - response, content = self.client.request('/api/poll/?id=1;id=2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 2 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_eq_same_or_simple(self, connectors): - response, content = self.client.request('/api/poll/?id=1,2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 2 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_gt_one(self, connectors): - response, content = self.client.request('/api/poll/?id>99') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 1 - - question = data[0]['question'] - assert question == 'What one question would you add to this survey?' - - def test_param_available_true(self, connectors): - response, content = self.client.request('/api/poll/?available=true') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 4 - - def test_param_available_false(self, connectors): - response, content = self.client.request('/api/poll/?available=false') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 4 - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceTraversal(BaseResourceTest): - - def test_item_attribute(self): - response, content = self.client.request('/api/poll/1/question/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert data[0] == 'Are you an innie or an outie?' - - def test_item_attribute_not_found(self): - response, content = self.client.request('/api/poll/1/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_item_attribute_not_found_path(self): - response, content = self.client.request('/api/poll/1/question/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_list_attribute(self): - response, content = self.client.request('/api/poll/question/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert data[0] == 'Are you an innie or an outie?' - assert data[-1] == 'What one question would you add to this survey?' - - def test_list_attribute_not_found(self): - response, content = self.client.request('/api/poll/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_list_attribute_not_found_path(self): - response, content = self.client.request('/api/poll/question/blah/') - - assert response.status == http.client.NOT_FOUND - - -@mark.bench('self.client.request', iterations=1000) -class TestResourcePagination(BaseResourceTest): - - def test_get_range(self): - response, content = self.client.request( - path='/api/poll/', - headers={ - 'range': 'items=10-20'}) - - assert response.status == http.client.PARTIAL_CONTENT - - data = json.loads(content.decode('utf-8')) - - assert len(data) == 11 - assert data[0]['question'] == 'What is your biggest pet peeve?' - - def test_get_single(self): - response, content = self.client.request( - path='/api/poll/', - headers={ - 'range': 'items=10'}) - - assert response.status == http.client.PARTIAL_CONTENT - - data = json.loads(content.decode('utf-8')) - - assert len(data) == 1 - assert data[0]['question'] == 'What is your biggest pet peeve?' diff --git a/tests/connectors/test_options.py b/tests/connectors/test_options.py deleted file mode 100644 index f69f04f..0000000 --- a/tests/connectors/test_options.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- -from armet import http -from .base import BaseResourceTest - - -class TestResourceOptions(BaseResourceTest): - - @classmethod - def setup_class(cls): - super(TestResourceOptions, cls).setup_class() - cls.right_path = '/api/left/' - cls.left_path = '/api/right/' - cls.right_origin = 'http://127.0.0.1:80' - cls.left_origin = 'http://10.0.0.1' - cls.default_method = 'GET' - - def test_origin_header(self, connectors): - # Check an endpoint with a specific origin. - # Try with no Origin request. - # Should just send back 200. - response, _ = self.client.options(self.right_path) - - assert response.status == http.client.OK - - # Try with Origin not in the list of allowed origins. - response, _ = self.client.options( - self.right_path, - headers={'Origin': self.left_origin}) - - assert response.status == http.client.OK - - # Try with the correct Origin. - response, _ = self.client.options( - self.right_path, - headers={'Origin': self.right_origin}) - - assert response.status == http.client.OK - - # Check an endpoint which allows all origins. - # Try with no Origin request. - # Should just send back 200. - response, _ = self.client.options(self.left_path) - - assert response.status == http.client.OK - - # Try with any random origin. - response, _ = self.client.options( - self.left_path, - headers={'Origin': self.left_origin}) - - assert response.status == http.client.OK - - def access_control_headers(self, header): - """ - Test each Access-Control-Allow header here, since they - all do the same thing. - """ - - # Check an endpoint that has a specific origin. - # Check with no origin. - response, _ = self.client.options(self.right_path) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Set the origin to the correct place. - response, _ = self.client.options( - self.right_path, - headers={ - 'Origin': self.right_origin, - 'Access-Control-Request-Method': self.default_method}) - - # Should give back a 200 and an Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Do it again with asterisk. - response, _ = self.client.options( - self.right_path, headers={'Origin': '*'}) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # TODO: Should probably check that we're getting the - # correct methods back. - - # Now try with an enpoint that allows any origin. - # Check with no origin. - response, _ = self.client.options(self.left_path) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Set the origin to the correct place. - response, _ = self.client.options( - self.left_path, - headers={ - 'Origin': self.left_origin, - 'Access-Control-Request-Method': self.default_method}) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Should probably check that we're getting the correct methods back. - - def test_access_control_allow_headers(self, connectors): - """Test the Access-Control-Allow-Headers header.""" - self.access_control_headers('Access-Control-Allow-Headers') - - def test_access_control_allow_methods(self, connectors): - """Test the Access-Control-Allow-Methods header.""" - self.access_control_headers('Access-Control-Allow-Methods') - - def test_access_control_allow_origin(self, connectors): - """Test the Access-Control-Allow-Origin header.""" - self.access_control_headers('Access-Control-Allow-Origin') diff --git a/tests/connectors/test_post.py b/tests/connectors/test_post.py deleted file mode 100644 index 91d6d4a..0000000 --- a/tests/connectors/test_post.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http, serializers, deserializers -from .base import BaseResourceTest - - -class TestResourcePost(BaseResourceTest): - - def test_post_single(self, connectors): - data = {'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.CREATED - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert data['question'] == 'Is anybody really out there?' - assert data['id'] == 101 - - -class TestResourceEcho(BaseResourceTest): - - @classmethod - def setup_class(cls): - super(TestResourceEcho, cls).setup_class() - - cls.body = {'x': ['1', '2', '3'], 'y': ['4', '5']} - - cls.serializers = {} - cls.serializers['json'] = serializers.JSONSerializer() - cls.serializers['url'] = serializers.URLSerializer() - - cls.deserializers = {} - cls.deserializers['json'] = deserializers.JSONDeserializer() - cls.deserializers['url'] = deserializers.URLDeserializer() - - def echo(self, in_format, out_format): - body = self.serializers[in_format].serialize(self.body) - response, content = self.client.post( - path='/api/echo/', body=body, - headers={ - 'Content-Type': self.serializers[in_format].media_types[0], - 'Accept': self.serializers[out_format].media_types[0]}) - - assert response.status == http.client.OK - - data = self.deserializers[out_format].deserialize(text=content) - - assert data == self.body - - def test_echo_json_json(self, connectors): - self.echo('json', 'json') - - def test_echo_json_url(self, connectors): - self.echo('json', 'url') - - def test_echo_url_url(self, connectors): - self.echo('url', 'url') - - def test_echo_url_json(self, connectors): - self.echo('url', 'json') diff --git a/tests/connectors/test_put.py b/tests/connectors/test_put.py deleted file mode 100644 index e8311f0..0000000 --- a/tests/connectors/test_put.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourcePut(BaseResourceTest): - - def test_put_existing(self, connectors): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert data['question'] == 'Is anybody really out there?' - assert data['id'] == 1 - - def test_put_nonexistant(self): - request_data = {'id': 101, 'question': 'Is there anybody home?'} - body = json.dumps(request_data) - response, content = self.client.put( - path='/api/poll/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert set(request_data.items()) <= set(data.items()) diff --git a/tests/connectors/test_resource.py b/tests/connectors/test_resource.py deleted file mode 100644 index 2f5668a..0000000 --- a/tests/connectors/test_resource.py +++ /dev/null @@ -1,339 +0,0 @@ -# -*- coding: utf-8 -*- -import armet -from armet import exceptions, resources -from .base import BaseResourceTest -import pytest -import json - - -class TestResource(BaseResourceTest): - - def test_modelless_model_resource(self, connectors): - with pytest.raises(exceptions.ImproperlyConfigured): - class Resource(resources.ModelResource): - pass - - def test_connectorless_resource(self, connectors): - # Unset configuration. - old = armet.use.config - armet.use.config = {} - - with pytest.raises(exceptions.ImproperlyConfigured): - class Resource(resources.Resource): - pass - - # Reset the configuration - armet.use.config = old - - def test_connectorless_abstract_resource(self, connectors): - # Unset configuration. - old = armet.use.config - armet.use.config = {} - - class Resource(resources.Resource): - class Meta: - abstract = True - - assert Resource.meta.abstract - - # Reset the configuration - armet.use.config = old - - -class TestAllowed(BaseResourceTest): - - def test_http_allowed_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_http_allowed_methods_to_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - assert 'read' in meta.allowed_operations - - def test_allowed_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - def test_allowed_operations_to_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - assert 'read' in meta.allowed_operations - - def test_allowed_methods_list(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - http_list_allowed_methods = 'PUT', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_allowed_methods_list_to_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - http_list_allowed_methods = 'PUT', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_allowed_operations_list(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - list_allowed_operations = 'update', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - def test_allowed_operations_list_to_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - list_allowed_operations = 'update', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - -class TestResolution(BaseResourceTest): - - def test_super_direct_resource(self, connectors): - response, content = self.client.get('/api/direct/') - - assert response.status == 200 - assert content.decode('utf8') == '42' - - def test_super_direct_model_resource(self, connectors): - response, content = self.client.get('/api/model-direct/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_indirect_resource(self, connectors): - response, content = self.client.get('/api/indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_twice_indirect_resource(self, connectors): - response, content = self.client.get('/api/twice-indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_twice_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-twice-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_thrice_indirect_resource(self, connectors): - response, content = self.client.get('/api/thrice-indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_thrice_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-thrice-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_mixin_resource(self, connectors): - response, content = self.client.get('/api/mixin/') - - assert response.status == 200 - assert content.decode('utf8') == 'Hello' - - -class TestConnectorResolution(BaseResourceTest): - - def test_direct_resource(self, connectors): - response, content = self.client.get('/api/direct-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_indirect_resource(self, connectors): - response, content = self.client.get('/api/indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_twice_indirect_resource(self, connectors): - response, content = self.client.get('/api/twice-indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_thrice_indirect_resource(self, connectors): - response, content = self.client.get('/api/thrice-indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_direct_model_resource(self, connectors): - response, content = self.client.get('/api/direct-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_twice_indirect_model_resource(self, connectors): - response, content = self.client.get( - '/api/twice-indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_thrice_indirect_model_resource(self, connectors): - response, content = self.client.get( - '/api/thrice-indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_direct_model_connector_mixin(self, connectors): - path = '/api/direct-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_indirect_model_connector_mixin(self, connectors): - path = '/api/indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_twice_indirect_model_connector_mixin(self, connectors): - path = '/api/twice-indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_thrice_indirect_model_connector_mixin(self, connectors): - path = '/api/thrice-indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 diff --git a/tests/connectors/test_validation.py b/tests/connectors/test_validation.py deleted file mode 100644 index f17f5cc..0000000 --- a/tests/connectors/test_validation.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourceValidation(BaseResourceTest): - - def test_post_too_low(self, connectors): - data = {'votes': -14} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert data['votes'] == ['Must be greater than 0.'] - - def test_post_too_high(self, connectors): - data = {'votes': 54} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert data['votes'] == ['Must be less than 51.'] - - def test_post_lots_wrong(self, connectors): - data = {'votes': 0, 'question': 'This'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert 'question' in data - assert data['votes'][0] == 'Must be greater than 0.' - assert data['question'][0] == 'Must be more than 15 characters.' - assert data['question'][1] == 'Must have at least one question mark.' diff --git a/tests/connectors/utils.py b/tests/connectors/utils.py deleted file mode 100644 index cbaba64..0000000 --- a/tests/connectors/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -from six.moves import reload_module -from importlib import import_module - - -def force_import_module(name): - if name in sys.modules: - return reload_module(sys.modules[name]) - - else: - return import_module(name) - - -def unload_module(name): - if name in sys.modules: - del sys.modules[name] diff --git a/tests/test_deserializers.py b/tests/test_deserializers.py deleted file mode 100644 index 333c8d7..0000000 --- a/tests/test_deserializers.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import unittest -import uuid -from armet import deserializers -import ujson as json -from pytest import mark - - -class DeserializerTestCase(unittest.TestCase): - - Deserializer = None - - @classmethod - def setup_class(cls): - cls.deserializer = cls.Deserializer() - - def deserialize(self, text): - self.data = self.deserializer.deserialize(text=text) - - -@mark.bench('self.deserialize', iterations=10000) -class JSONDeserializerTestCase(DeserializerTestCase): - - Deserializer = deserializers.JSONDeserializer - - def test_scalar(self): - self.assertRaises(ValueError, self.deserialize, 124) - - def test_number(self): - self.deserialize(b'[42]') - - assert self.data == [42] - - def test_boolean(self): - self.deserialize(b'[true]') - - assert self.data == [True] - - self.deserialize(b'[false]') - - assert self.data == [False] - - def test_array(self): - self.deserialize(b'[1, 2, 3]') - - assert self.data == [1, 2, 3] - - def test_dict(self): - self.deserialize(b'{"x": 2, "y": "bob"}') - - assert self.data == {"x": 2, "y": "bob"} - - def test_dict_array(self): - self.deserialize(b'{"x": [1, 2], "y": "bob"}') - - assert self.data == {"x": [1, 2], "y": "bob"} - - def test_large(self): - payload_item = { - 'organization': uuid.uuid4().hex, - 'user': uuid.uuid4().hex, - 'id': uuid.uuid4().hex, - 'is_active': False, - 'is_pending': True, - # 'members': [ - # { - # 'id': uuid.uuid4().hex, - # 'organization': uuid.uuid4().hex, - # 'is_dead': False - # } - # ], - } - - payload = [payload_item] * 100 - - text = json.dumps(payload, ensure_ascii=False) - - self.deserialize(text.encode('utf8')) - - assert self.data == payload - - -@mark.bench('self.deserialize', iterations=10000) -class URLDeserializerTestCase(DeserializerTestCase): - - Deserializer = deserializers.URLDeserializer - - def test_scalar(self): - self.assertRaises(ValueError, self.deserialize, 124) - - def test_sequence(self): - self.deserialize(b'x=2&y=51') - - assert self.data == {'x': ['2'], 'y': ['51']} - - def test_multi_sequence(self): - self.deserialize(b'x=2&y=51&x=781&y=165') - - assert self.data == {"x": ['2', '781'], "y": ['51', '165']} diff --git a/tests/test_parse.py b/tests/test_parse.py deleted file mode 100644 index 811e9b3..0000000 --- a/tests/test_parse.py +++ /dev/null @@ -1,175 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import unittest -from armet import resources -from pytest import mark - - -# @mark.bench('self.Resource.parse', iterations=10000) -@mark.bench('self.request', iterations=10000) -class ParseTestCase(unittest.TestCase): - - def setUp(self): - super(ParseTestCase, self).setUp() - - # Construct a resource. - class Resource(resources.ManagedResource): - class Meta: - abstract = True - - # Store the resource - self.Resource = Resource - - def request(self, url): - # Execute the parse method. - _, arguments, _ = self.Resource.parse(url) - - # Assert that we got enough of them. - assert 'slug' in arguments - assert 'query' in arguments - assert 'extensions' in arguments - assert 'directives' in arguments - assert 'path' in arguments - - # Return the arguments - return arguments - - def assert_arguments(self, arguments, **kwargs): - for argument in kwargs: - value = arguments.pop(argument) - assert value == kwargs[argument] - - for name, value in six.iteritems(arguments): - test = [] if name.endswith('s') else None - assert value == test - - def test_simple(self): - arguments = self.request('/') - - self.assert_arguments(arguments) - - def test_slug(self): - arguments = self.request('/123/') - - self.assert_arguments(arguments, slug='123') - - def test_slug_long(self): - arguments = self.request('/sdf-sdg-sgh-sh234-bf/') - - self.assert_arguments(arguments, slug='sdf-sdg-sgh-sh234-bf') - - def test_query(self): - arguments = self.request('(stuff=32)/') - - self.assert_arguments(arguments, query='stuff=32') - - def test_query_long(self): - query = 'x=32&y=61;z=135,15&x=16;!(h=134&b=324)' - arguments = self.request('({})/'.format(query)) - - self.assert_arguments(arguments, query=query) - - def test_query_directives(self): - query = 'x:asc=32&y:desc=61;z:-=135,15&x:descending=16;!(h=134&b=324)' - arguments = self.request('({})/'.format(query)) - - self.assert_arguments(arguments, query=query) - - def test_directive(self): - arguments = self.request(':random/') - - self.assert_arguments(arguments, directives=['random']) - - def test_directives(self): - directives = ['random', 'rand', 'other', 'weird', 'wan'] - url = ':{}/'.format(':'.join(directives)) - arguments = self.request(url) - - self.assert_arguments(arguments, directives=directives) - - def test_directives_query(self): - directives = ['random', 'rand', 'other', 'weird', 'wan'] - url = ':{}(x=4&y=234&z=34)/'.format(':'.join(directives)) - arguments = self.request(url) - - self.assert_arguments( - arguments, directives=directives, query='x=4&y=234&z=34') - - def test_path(self): - path = 'this/is/a/path/to/somewhere' - arguments = self.request('/124/{}/'.format(path)) - - self.assert_arguments(arguments, slug='124', path=path) - - def test_query_path(self): - query = 'x=34&t:desc=324&(x=234&x:asc=2134)' - path = 'this/is/a(x=324)/path(y=3124&x=23)/to/somewhere' - arguments = self.request('({})/555/{}/'.format( - query, path)) - - self.assert_arguments(arguments, slug='555', query=query, path=path) - - def test_path_query(self): - path = 'this/is/a(x=324)/path(y=3124&x=23)/to/somewhere' - arguments = self.request('/124/{}/'.format(path)) - - self.assert_arguments(arguments, slug='124', path=path) - - def test_extension(self): - ext = 'json' - arguments = self.request('.{}/'.format(ext)) - - self.assert_arguments(arguments, extensions=[ext]) - - def test_extensions(self): - exts = ['schema', 'json'] - arguments = self.request('.{}/'.format('.'.join(exts))) - - self.assert_arguments(arguments, extensions=exts) - - def test_extensions_slug(self): - exts = ['schema', 'json'] - arguments = self.request('/234.{}/'.format('.'.join(exts))) - - self.assert_arguments(arguments, slug='234', extensions=exts) - - def test_extensions_path(self): - exts = ['schema', 'json'] - url = '/234/from/this/to/that.{}/'.format('.'.join(exts)) - arguments = self.request(url) - - self.assert_arguments( - arguments, slug='234', extensions=exts, - path='from/this/to/that') - - def test_all(self): - extensions = ['schema', 'json'] - directives = ['random', 'rand', 'okay', 'because'] - slug = '2342fsdg8920sd-235-f-g325-sg23-532' - path = '/from/this(x=235)/to:random/that(because)/and/this' - query = 'x=345&xv=32;!(x=34)' - url = ':{}({})/{}/{}.{}/'.format( - ':'.join(directives), - query, - slug, - path, - '.'.join(extensions)) - arguments = self.request(url) - - self.assert_arguments( - arguments, - slug=slug, - extensions=extensions, - directives=directives, - path=path, - query=query) - - def test_extension_path(self): - url = '/23/from/this.hub/32/from.hub/32/' - arguments = self.request(url) - - self.assert_arguments( - arguments, - slug='23', - path='from/this.hub/32/from.hub/32') diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 853aa50..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import unittest -import six -from armet.query import parser, constants -from pytest import mark - - -@mark.bench('parser.parse', iterations=10000) -class QueryTestCase(unittest.TestCase): - - def parse(self, text): - """Simple convenience function to unwrap the array of parameters.""" - return parser.parse(text).parsed - - def test_empty(self): - """Test an empty query string.""" - item = self.parse('') - - assert isinstance(item, parser.NoopQuerySegment) - - def test_simple_filter(self): - item = self.parse('foo=bar') - - assert item.path == ['foo'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['bar'] - - def test_binary(self): - item = self.parse(b'foo=bar') - - assert item.path == ['foo'] - assert item.values == ['bar'] - - def test_negation(self): - queries = ['foo!=bar', 'foo.not=bar'] - for query in queries: - item = self.parse(query) - - assert item.negated - - def test_relational_filter(self): - item = self.parse('bread.sticks=delicious') - - assert item.path == ['bread', 'sticks'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['delicious'] - - def test_values(self): - item = self.parse('fruit=apples,oranges') - - assert item.path == ['fruit'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['apples', 'oranges'] - - def test_bogus(self): - """Test some bogusy query strings.""" - queries = [ - 'foo:asc&;bar:desc', - 'foo.lte<=3', - 'foo.!negate', - 'lte=3', - ] - for query in queries: - self.assertRaises(ValueError, self.parse, query) - - def test_operations(self): - for name, symbol in constants.OPERATORS: - item = self.parse('crazy.{}=true'.format(name)) - - assert item.path == ['crazy'] - assert item.operator == constants.OPERATOR_SUFFIX_MAP[name] - assert not item.negated - assert item.values == ['true'] - - if symbol is not None: - item = self.parse('crazy{}true'.format(symbol)) - - assert item.path == ['crazy'] - assert item.operator == constants.OPERATOR_EQUALITY_MAP[symbol] - assert not item.negated - assert item.values == ['true'] - - def test_fusion(self): - """Test something from everything combined""" - q = ('the.rolling.stones.iregex.not:asc=sympathy,for,the,devil;' - '!(guns.n.roses=paradise,city&queen:asc)') - - item = self.parse(q) - - # I'm lazy and don't want to walk the tree, so lets just test the repr. - assert (six.text_type(repr(item)) == - "(the.rolling.stones.iregex.not:asc :iexact 'sympathy' " - "| 'for' | 'the' | 'devil') OR NOT (guns.n.roses :iexact " - "'paradise' | 'city') AND (queen :iexact '')") - - def test_grouping(self): - item = self.parse('foo=bar&(a=b;b=c)') - - assert item.left.path == ['foo'] - assert item.right.left.path == ['a'] - assert item.right.right.path == ['b'] diff --git a/tests/test_serializers.py b/tests/test_serializers.py deleted file mode 100644 index 67243cb..0000000 --- a/tests/test_serializers.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import ujson as json -import six -import uuid -from armet import serializers -from pytest import mark, raises - - -class TestSerializer: - - media_type = None - - Serializer = None - - @classmethod - def setup_class(cls): - cls.serializer = cls.Serializer() - - def serialize(self, data): - content = self.serializer.serialize(data) - if content and isinstance(content, six.binary_type): - content = content.decode('utf8') - - self.content = content - - -@mark.bench('self.serializer.serialize', iterations=10000) -class TestJSONSerializer(TestSerializer): - - media_type = 'application/json' - - Serializer = serializers.JSONSerializer - - def test_none(self): - self.serialize(None) - - assert self.content == '{}' - - def test_number(self): - self.serialize(42) - - assert self.content == '[42]' - - def test_boolean(self): - self.serialize(True) - - assert self.content == '[true]' - - self.serialize(False) - - assert self.content == '[false]' - - def test_array(self): - self.serialize([1, 2, 3]) - - assert self.content == '[1,2,3]' - - def test_array_nested(self): - self.serialize([1, [2, 4, 5], 3]) - - assert self.content == '[1,[2,4,5],3]' - - def test_dict(self): - message = {'x': 1, 'y': 2} - self.serialize(message) - - assert json.loads(self.content) == message - - def test_generator(self): - self.serialize(x for x in range(10)) - - assert self.content == '[0,1,2,3,4,5,6,7,8,9]' - - def test_large(self): - payload_item = { - 'organization': uuid.uuid4().hex, - 'user': uuid.uuid4().hex, - 'id': uuid.uuid4().hex, - 'is_active': False, - 'is_pending': True, - 'members': [ - { - 'id': uuid.uuid4().hex, - 'organization': uuid.uuid4().hex, - 'is_dead': False - } - ], - } - - payload = [payload_item] * 100 - - self.serialize(payload) - - assert self.content == json.dumps(payload, ensure_ascii=False) - - -@mark.bench('self.serializer.serialize', iterations=10000) -class TestURLSerializer(TestSerializer): - - media_type = 'application/x-www-form-urlencoded' - - Serializer = serializers.URLSerializer - - def test_none(self): - self.serialize(None) - - assert self.content == '' - - def test_nested(self): - self.serialize({"foo": [1, 2, 3]}) - - assert self.content == 'foo=1&foo=2&foo=3' - - def test_impossible(self): - data = [{"foo": "bar"}, {"bar": "baz"}] - with raises(ValueError): - self.serialize(data) - - def test_tuple(self): - self.serialize([('foo', 'bar'), ('bar', 'baz')]) - - assert self.content == "foo=bar&bar=baz" diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 831a577..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import utils -from pytest import mark - - -@mark.bench('utils.cons', number=1000000) -class TestConstruction: - - def test_mapping(self): - lhs = {'x': 10, 'y': 20} - rhs = {'z': 15} - - value = utils.cons(lhs, rhs) - - assert value == {'x': 10, 'y': 20, 'z': 15} - - def test_list(self): - lhs = [10, 20] - rhs = [15] - - value = utils.cons(lhs, rhs) - - assert value == [10, 20, 15] - - def test_scalar(self): - lhs = [10, 20] - rhs = 15 - - value = utils.cons(lhs, rhs) - - assert value == [10, 20, 15] - - def test_string(self): - lhs = ['10', 20] - rhs = '15' - - value = utils.cons(lhs, rhs) - - assert value == ['10', 20, '15'] - - -@mark.bench('utils.dasherize', number=1000000) -class TestDasherize: - - def test_word(self): - assert utils.dasherize('word') == 'word' - - def test_camel(self): - assert utils.dasherize('camelCase') == 'camel-case' - - def test_pascal(self): - assert utils.dasherize('PascalCase') == 'pascal-case' - - def test_underscore(self): - assert utils.dasherize('under_score') == 'under-score' - - def test_dashed(self): - assert utils.dasherize('dashed-words') == 'dashed-words' From 3c303e11242db99ead4190e8f5432c828adb0a45 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 13:56:46 -0700 Subject: [PATCH 002/118] Initial. --- TASKS | 23 +++++++++++++++++++++++ armet/__init__.py | 0 armet/_version.py | 2 ++ 3 files changed, 25 insertions(+) create mode 100644 TASKS create mode 100644 armet/__init__.py create mode 100644 armet/_version.py diff --git a/TASKS b/TASKS new file mode 100644 index 0000000..71c39ae --- /dev/null +++ b/TASKS @@ -0,0 +1,23 @@ + ☐ Router + ☐ WSGI + ☐ Flask + ☐ Tornado + ☐ Twisted + ☐ Spawning + ☐ Rocket + + ☐ Decoder + ☐ JSON + ☐ XML + ☐ Form + ☐ URL + ☐ YAML + ☐ pList + + ☐ Encoder + ☐ JSON + ☐ XML + ☐ Form + ☐ URL + ☐ YAML + ☐ pList diff --git a/armet/__init__.py b/armet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/armet/_version.py b/armet/_version.py new file mode 100644 index 0000000..1056fd6 --- /dev/null +++ b/armet/_version.py @@ -0,0 +1,2 @@ +__version_info__ = (0, 5, 0) +__version__ = '.'.join(map(str, __version_info__)) From 35405a852b19b699600023ea40a8f691fc90e3f2 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 15:17:24 -0700 Subject: [PATCH 003/118] Methods to find, register, and purge encoders. --- armet/encoders.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_encoders.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 armet/encoders.py create mode 100644 tests/test_encoders.py diff --git a/armet/encoders.py b/armet/encoders.py new file mode 100644 index 0000000..08ec7e5 --- /dev/null +++ b/armet/encoders.py @@ -0,0 +1,37 @@ + +# Encoder registry +_ENCODERS = {} +_MIME_TYPE_ENCODERS = {} + + +def find(*, mime_type=None, name=None): + # Attempt to find a compliant encoder given eitehr a mime_type or encoder + # name. Prioritize the mime-type. + + if mime_type is not None: + return _MIME_TYPE_ENCODERS[mime_type] + elif name is not None: + return _ENCODERS[name] + + raise TypeError('Either mime_type or name is required.') + + +def register(encoder, names, mime_types): + # Register the encoder provided in the global list of available encoders. + _ENCODERS.update({x: encoder for x in names}) + _MIME_TYPE_ENCODERS.update({x: encoder for x in mime_types}) + + +def purge(encoder): + # Remove the encoder from the global list of available encoders. + for registry in (_ENCODERS, _MIME_TYPE_ENCODERS): + collection = set() + for name, test in registry.items(): + if encoder is test: + collection.add(name) + + # Need to apply the deletions after iterating over the registry + # otherwise an exception is thrown because the dictionary changed + # size during iteration. + for key in collection: + del registry[key] diff --git a/tests/test_encoders.py b/tests/test_encoders.py new file mode 100644 index 0000000..c2a0c34 --- /dev/null +++ b/tests/test_encoders.py @@ -0,0 +1,36 @@ +from armet import encoders +import pytest + + +class ExampleEncoder: + pass + + +class TestEncoderLookup: + def setup(self): + encoders.register( + ExampleEncoder, + names=['test', 'example'], + mime_types=['application/octet-stream', 'test']) + + def teardown(self): + encoders.purge(ExampleEncoder) + + def test_lookup_by_mimetype(self): + mime = 'test' + assert encoders.find(mime_type=mime) is ExampleEncoder + + mime = 'application/octet-stream' + assert encoders.find(mime_type=mime) is ExampleEncoder + + def test_lookup_by_name(self): + assert encoders.find(name='test') is ExampleEncoder + assert encoders.find(name='example') is ExampleEncoder + + def test_lookup_failure(self): + assert pytest.raises(KeyError, encoders.find, name='missing') + assert pytest.raises(KeyError, encoders.find, mime_type='missing') + + def test_lookup_bad_args(self): + with pytest.raises(TypeError): + encoders.find() From b8bf5501a38224d3b13781a4bbb15568c21e6591 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 15:24:05 -0700 Subject: [PATCH 004/118] Generalize the encoder registry and expose it in both the encoders and decoders. --- armet/decoders.py | 9 +++++++++ armet/encoders.py | 40 ++++++--------------------------------- armet/transcoders.py | 40 +++++++++++++++++++++++++++++++++++++++ tests/test_decoders.py | 7 +++++++ tests/test_encoders.py | 37 ++++-------------------------------- tests/test_transcoders.py | 37 ++++++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 67 deletions(-) create mode 100644 armet/decoders.py create mode 100644 armet/transcoders.py create mode 100644 tests/test_decoders.py create mode 100644 tests/test_transcoders.py diff --git a/armet/decoders.py b/armet/decoders.py new file mode 100644 index 0000000..0a4aa1b --- /dev/null +++ b/armet/decoders.py @@ -0,0 +1,9 @@ +from .transcoders import TranscoderRegistry + + +# Create our encoder registry and pull methods off it for easy access. +registry = TranscoderRegistry() + +find = registry.find +purge = registry.purge +register = registry.register diff --git a/armet/encoders.py b/armet/encoders.py index 08ec7e5..0a4aa1b 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,37 +1,9 @@ +from .transcoders import TranscoderRegistry -# Encoder registry -_ENCODERS = {} -_MIME_TYPE_ENCODERS = {} +# Create our encoder registry and pull methods off it for easy access. +registry = TranscoderRegistry() -def find(*, mime_type=None, name=None): - # Attempt to find a compliant encoder given eitehr a mime_type or encoder - # name. Prioritize the mime-type. - - if mime_type is not None: - return _MIME_TYPE_ENCODERS[mime_type] - elif name is not None: - return _ENCODERS[name] - - raise TypeError('Either mime_type or name is required.') - - -def register(encoder, names, mime_types): - # Register the encoder provided in the global list of available encoders. - _ENCODERS.update({x: encoder for x in names}) - _MIME_TYPE_ENCODERS.update({x: encoder for x in mime_types}) - - -def purge(encoder): - # Remove the encoder from the global list of available encoders. - for registry in (_ENCODERS, _MIME_TYPE_ENCODERS): - collection = set() - for name, test in registry.items(): - if encoder is test: - collection.add(name) - - # Need to apply the deletions after iterating over the registry - # otherwise an exception is thrown because the dictionary changed - # size during iteration. - for key in collection: - del registry[key] +find = registry.find +purge = registry.purge +register = registry.register diff --git a/armet/transcoders.py b/armet/transcoders.py new file mode 100644 index 0000000..d4fe6cf --- /dev/null +++ b/armet/transcoders.py @@ -0,0 +1,40 @@ + + +class TranscoderRegistry: + """A registry used for registering and removing encoders and decoders. + """ + def __init__(self): + self.encoders = {} + self.mime_types = {} + + def find(self, *, mime_type=None, name=None): + # Attempt to find a compliant encoder given eitehr a mime_type or + # encoder name. Prioritize the mime-type. + + if mime_type is not None: + return self.mime_types[mime_type] + elif name is not None: + return self.encoders[name] + + raise TypeError('Either mime_type or name is required.') + + def register(self, encoder, names, mime_types): + # Register the transcoder provided in the global list of + # transcoders. + + self.encoders.update({x: encoder for x in names}) + self.mime_types.update({x: encoder for x in mime_types}) + + def purge(self, encoder): + # Remove the encoder from the global list of available encoders. + for registry in (self.encoders, self.mime_types): + collection = set() + for name, test in registry.items(): + if encoder is test: + collection.add(name) + + # Need to apply the deletions after iterating over the registry + # otherwise an exception is thrown because the dictionary changed + # size during iteration. + for key in collection: + del registry[key] diff --git a/tests/test_decoders.py b/tests/test_decoders.py new file mode 100644 index 0000000..18ae158 --- /dev/null +++ b/tests/test_decoders.py @@ -0,0 +1,7 @@ +from armet import decoders + + +def test_decoders_api_methods(): + assert decoders.find + assert decoders.register + assert decoders.purge diff --git a/tests/test_encoders.py b/tests/test_encoders.py index c2a0c34..aca183c 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -1,36 +1,7 @@ from armet import encoders -import pytest -class ExampleEncoder: - pass - - -class TestEncoderLookup: - def setup(self): - encoders.register( - ExampleEncoder, - names=['test', 'example'], - mime_types=['application/octet-stream', 'test']) - - def teardown(self): - encoders.purge(ExampleEncoder) - - def test_lookup_by_mimetype(self): - mime = 'test' - assert encoders.find(mime_type=mime) is ExampleEncoder - - mime = 'application/octet-stream' - assert encoders.find(mime_type=mime) is ExampleEncoder - - def test_lookup_by_name(self): - assert encoders.find(name='test') is ExampleEncoder - assert encoders.find(name='example') is ExampleEncoder - - def test_lookup_failure(self): - assert pytest.raises(KeyError, encoders.find, name='missing') - assert pytest.raises(KeyError, encoders.find, mime_type='missing') - - def test_lookup_bad_args(self): - with pytest.raises(TypeError): - encoders.find() +def test_encoders_api_methods(): + assert encoders.find + assert encoders.register + assert encoders.purge diff --git a/tests/test_transcoders.py b/tests/test_transcoders.py new file mode 100644 index 0000000..4175291 --- /dev/null +++ b/tests/test_transcoders.py @@ -0,0 +1,37 @@ +from armet.transcoders import TranscoderRegistry +import pytest + + +class ExampleEncoder: + pass + + +class TestTranscoderRegistry: + def setup(self): + self.registry = TranscoderRegistry() + self.registry.register( + ExampleEncoder, + names=['test', 'example'], + mime_types=['application/octet-stream', 'test']) + + def teardown(self): + self.registry.purge(ExampleEncoder) + + def test_lookup_by_mimetype(self): + mime = 'test' + assert self.registry.find(mime_type=mime) is ExampleEncoder + + mime = 'application/octet-stream' + assert self.registry.find(mime_type=mime) is ExampleEncoder + + def test_lookup_by_name(self): + assert self.registry.find(name='test') is ExampleEncoder + assert self.registry.find(name='example') is ExampleEncoder + + def test_lookup_failure(self): + assert pytest.raises(KeyError, self.registry.find, name='missing') + assert pytest.raises(KeyError, self.registry.find, mime_type='missing') + + def test_lookup_bad_args(self): + with pytest.raises(TypeError): + self.registry.find() From 01f4cf233df26d1fda66ffe75a11a719644d12bc Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 15:25:20 -0700 Subject: [PATCH 005/118] Add pytest to the setup. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 937f870..1ecdf37 100644 --- a/setup.py +++ b/setup.py @@ -32,5 +32,6 @@ url='http://github.com/armet/python-armet', packages=find_packages('.'), install_requires=[ + 'pytest', # Testing! ], ) From 8be2ec0fbc367745a00454adc1c0b190f0854704 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 16:03:41 -0700 Subject: [PATCH 006/118] Add base encoder class. --- armet/encoders.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/armet/encoders.py b/armet/encoders.py index 0a4aa1b..0e0c1ed 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -7,3 +7,12 @@ find = registry.find purge = registry.purge register = registry.register + + +class Encoder: + """Base class for all encoders.""" + + def encode(self, data): + """Entrypoint for logic used to encode an entire block. + If a type is not encodable, a TypeError may be raised.""" + raise NotImplementedError From 0aae5e6a13f190492293674a5b945d2f9cd050d1 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 16:35:46 -0700 Subject: [PATCH 007/118] Add mimetype parsing --- armet/transcoders.py | 25 +++++++++++++++++++++---- setup.py | 1 + tests/test_transcoders.py | 28 ++++++++++++++++++++++++---- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/armet/transcoders.py b/armet/transcoders.py index d4fe6cf..8b543c7 100644 --- a/armet/transcoders.py +++ b/armet/transcoders.py @@ -1,3 +1,4 @@ +import mimeparse class TranscoderRegistry: @@ -7,18 +8,34 @@ def __init__(self): self.encoders = {} self.mime_types = {} - def find(self, *, mime_type=None, name=None): + def find(self, *, media_range=None, name=None, mime_type=None): # Attempt to find a compliant encoder given eitehr a mime_type or - # encoder name. Prioritize the mime-type. + # encoder name. Prioritize the mime-type, then name, then media_range. if mime_type is not None: return self.mime_types[mime_type] elif name is not None: return self.encoders[name] + elif media_range is not None: + return self.find_media_range(media_range) - raise TypeError('Either mime_type or name is required.') + raise TypeError( + 'At least one parameter is required: ' + 'media_range, mime_type, name.') - def register(self, encoder, names, mime_types): + def find_media_range(self, media_range): + try: + found = mimeparse.best_match(self.mime_types.keys(), media_range) + + except ValueError: + raise TypeError('Malformed media range.') + + if found is None: + raise KeyError + + return self.mime_types[found] + + def register(self, encoder, names=[], mime_types=[]): # Register the transcoder provided in the global list of # transcoders. diff --git a/setup.py b/setup.py index 1ecdf37..0c49f2d 100644 --- a/setup.py +++ b/setup.py @@ -33,5 +33,6 @@ packages=find_packages('.'), install_requires=[ 'pytest', # Testing! + 'python-mimeparse', ], ) diff --git a/tests/test_transcoders.py b/tests/test_transcoders.py index 4175291..69a7348 100644 --- a/tests/test_transcoders.py +++ b/tests/test_transcoders.py @@ -6,31 +6,51 @@ class ExampleEncoder: pass +class CounterExampleEncoder: + pass + + class TestTranscoderRegistry: def setup(self): self.registry = TranscoderRegistry() self.registry.register( ExampleEncoder, names=['test', 'example'], - mime_types=['application/octet-stream', 'test']) + mime_types=['application/octet-stream', 'test/test']) + + self.registry.register( + CounterExampleEncoder, + mime_types=['application/xbel+xml', 'example/xml']) def teardown(self): self.registry.purge(ExampleEncoder) - def test_lookup_by_mimetype(self): - mime = 'test' + def test_lookup_by_mime_type(self): + mime = 'test/test' assert self.registry.find(mime_type=mime) is ExampleEncoder mime = 'application/octet-stream' assert self.registry.find(mime_type=mime) is ExampleEncoder + def test_lookup_by_media_range(self): + mime = 'text/*;q=0.5,*/*; q=0.1' + assert self.registry.find(media_range=mime) is CounterExampleEncoder + + mime = 'test/*;q=0.5,*/*; q=0.1' + assert self.registry.find(media_range=mime) is ExampleEncoder + + def test_malformed_media_range(self): + with pytest.raises(TypeError): + self.registry.find(media_range='asdf') + def test_lookup_by_name(self): assert self.registry.find(name='test') is ExampleEncoder assert self.registry.find(name='example') is ExampleEncoder def test_lookup_failure(self): assert pytest.raises(KeyError, self.registry.find, name='missing') - assert pytest.raises(KeyError, self.registry.find, mime_type='missing') + mime = 'application/missing;q=0.5' + assert pytest.raises(KeyError, self.registry.find, media_range=mime) def test_lookup_bad_args(self): with pytest.raises(TypeError): From e9a630f14650e41a8a5c7302114a01487679daa9 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 17:47:42 -0700 Subject: [PATCH 008/118] Various small changes after review. --- armet/codecs.py | 57 +++++++++++++++++++ armet/decoders.py | 10 ++-- armet/encoders.py | 19 ++----- armet/transcoders.py | 57 ------------------- setup.py | 2 +- tests/{test_transcoders.py => test_codecs.py} | 13 ++--- tests/test_decoders.py | 2 +- tests/test_encoders.py | 2 +- 8 files changed, 75 insertions(+), 87 deletions(-) create mode 100644 armet/codecs.py delete mode 100644 armet/transcoders.py rename tests/{test_transcoders.py => test_codecs.py} (84%) diff --git a/armet/codecs.py b/armet/codecs.py new file mode 100644 index 0000000..8c5eded --- /dev/null +++ b/armet/codecs.py @@ -0,0 +1,57 @@ +import mimeparse + + +class CodecRegistry: + """A registry used for registering and removing encoders and decoders. + """ + + def __init__(self): + self._encoders = {} + self._mime_types = {} + + def find(self, *, media_range=None, name=None, mime_type=None): + """ + Attempt to find a compliant encoder given either a mime_type or + encoder name. Prioritize the mime-type, then name, then media_range. + """ + + if mime_type is not None: + return self._mime_types[mime_type] + + if name is not None: + return self._encoders[name] + + if media_range is not None: + return self._find_media_range(media_range) + + raise TypeError( + 'At least one parameter is required: ' + 'media_range, mime_type, or name.') + + def _find_media_range(self, media_range): + try: + found = mimeparse.best_match(self._mime_types.keys(), media_range) + + except ValueError: + raise KeyError('Malformed media range.') + + if found is None: + raise KeyError + + return self._mime_types[found] + + def register(self, encoder, names=(), mime_types=()): + """Register the transcoder provided in the global list of transcoders. + """ + + self._encoders.update({x: encoder for x in names}) + self._mime_types.update({x: encoder for x in mime_types}) + + def remove(self, encoder): + """Remove the encoder from the global list of available encoders. + """ + + for registry in (self._encoders, self._mime_types): + for name, test in list(registry.items()): + if encoder is test: + del registry[name] diff --git a/armet/decoders.py b/armet/decoders.py index 0a4aa1b..a872516 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,9 +1,9 @@ -from .transcoders import TranscoderRegistry +from .codecs import CodecRegistry # Create our encoder registry and pull methods off it for easy access. -registry = TranscoderRegistry() +_registry = CodecRegistry() -find = registry.find -purge = registry.purge -register = registry.register +find = _registry.find +remove = _registry.remove +register = _registry.register diff --git a/armet/encoders.py b/armet/encoders.py index 0e0c1ed..a872516 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,18 +1,9 @@ -from .transcoders import TranscoderRegistry +from .codecs import CodecRegistry # Create our encoder registry and pull methods off it for easy access. -registry = TranscoderRegistry() +_registry = CodecRegistry() -find = registry.find -purge = registry.purge -register = registry.register - - -class Encoder: - """Base class for all encoders.""" - - def encode(self, data): - """Entrypoint for logic used to encode an entire block. - If a type is not encodable, a TypeError may be raised.""" - raise NotImplementedError +find = _registry.find +remove = _registry.remove +register = _registry.register diff --git a/armet/transcoders.py b/armet/transcoders.py deleted file mode 100644 index 8b543c7..0000000 --- a/armet/transcoders.py +++ /dev/null @@ -1,57 +0,0 @@ -import mimeparse - - -class TranscoderRegistry: - """A registry used for registering and removing encoders and decoders. - """ - def __init__(self): - self.encoders = {} - self.mime_types = {} - - def find(self, *, media_range=None, name=None, mime_type=None): - # Attempt to find a compliant encoder given eitehr a mime_type or - # encoder name. Prioritize the mime-type, then name, then media_range. - - if mime_type is not None: - return self.mime_types[mime_type] - elif name is not None: - return self.encoders[name] - elif media_range is not None: - return self.find_media_range(media_range) - - raise TypeError( - 'At least one parameter is required: ' - 'media_range, mime_type, name.') - - def find_media_range(self, media_range): - try: - found = mimeparse.best_match(self.mime_types.keys(), media_range) - - except ValueError: - raise TypeError('Malformed media range.') - - if found is None: - raise KeyError - - return self.mime_types[found] - - def register(self, encoder, names=[], mime_types=[]): - # Register the transcoder provided in the global list of - # transcoders. - - self.encoders.update({x: encoder for x in names}) - self.mime_types.update({x: encoder for x in mime_types}) - - def purge(self, encoder): - # Remove the encoder from the global list of available encoders. - for registry in (self.encoders, self.mime_types): - collection = set() - for name, test in registry.items(): - if encoder is test: - collection.add(name) - - # Need to apply the deletions after iterating over the registry - # otherwise an exception is thrown because the dictionary changed - # size during iteration. - for key in collection: - del registry[key] diff --git a/setup.py b/setup.py index 0c49f2d..93cea36 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ url='http://github.com/armet/python-armet', packages=find_packages('.'), install_requires=[ - 'pytest', # Testing! + 'pytest', 'python-mimeparse', ], ) diff --git a/tests/test_transcoders.py b/tests/test_codecs.py similarity index 84% rename from tests/test_transcoders.py rename to tests/test_codecs.py index 69a7348..479749d 100644 --- a/tests/test_transcoders.py +++ b/tests/test_codecs.py @@ -1,4 +1,4 @@ -from armet.transcoders import TranscoderRegistry +from armet.codecs import CodecRegistry import pytest @@ -10,9 +10,9 @@ class CounterExampleEncoder: pass -class TestTranscoderRegistry: +class TestCodecRegistry: def setup(self): - self.registry = TranscoderRegistry() + self.registry = CodecRegistry() self.registry.register( ExampleEncoder, names=['test', 'example'], @@ -22,9 +22,6 @@ def setup(self): CounterExampleEncoder, mime_types=['application/xbel+xml', 'example/xml']) - def teardown(self): - self.registry.purge(ExampleEncoder) - def test_lookup_by_mime_type(self): mime = 'test/test' assert self.registry.find(mime_type=mime) is ExampleEncoder @@ -33,14 +30,14 @@ def test_lookup_by_mime_type(self): assert self.registry.find(mime_type=mime) is ExampleEncoder def test_lookup_by_media_range(self): - mime = 'text/*;q=0.5,*/*; q=0.1' + mime = 'example/*;q=0.5,*/*; q=0.1' assert self.registry.find(media_range=mime) is CounterExampleEncoder mime = 'test/*;q=0.5,*/*; q=0.1' assert self.registry.find(media_range=mime) is ExampleEncoder def test_malformed_media_range(self): - with pytest.raises(TypeError): + with pytest.raises(KeyError): self.registry.find(media_range='asdf') def test_lookup_by_name(self): diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 18ae158..af8f518 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -4,4 +4,4 @@ def test_decoders_api_methods(): assert decoders.find assert decoders.register - assert decoders.purge + assert decoders.remove diff --git a/tests/test_encoders.py b/tests/test_encoders.py index aca183c..726219a 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -4,4 +4,4 @@ def test_encoders_api_methods(): assert encoders.find assert encoders.register - assert encoders.purge + assert encoders.remove From c4da7baab08c20ab44e828b9b4077d7f8c55183a Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 18:19:30 -0700 Subject: [PATCH 009/118] Add coverage and PEP8 assertion to testing suite. --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 93cea36..952aac7 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,8 @@ packages=find_packages('.'), install_requires=[ 'pytest', + 'pytest-pep8', + 'pytest-cov', 'python-mimeparse', ], ) From 1fc1826c60fbddd762420adb9f2382b9933b91ff Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 18:19:53 -0700 Subject: [PATCH 010/118] Allow encoders and deocoders to be removed by name or mime type. --- armet/codecs.py | 31 +++++++++++++++++++++++-------- armet/decoders.py | 4 ++-- armet/encoders.py | 4 ++-- tests/test_codecs.py | 14 ++++++++++++++ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/armet/codecs.py b/armet/codecs.py index 8c5eded..052b936 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -6,7 +6,7 @@ class CodecRegistry: """ def __init__(self): - self._encoders = {} + self._codecs = {} self._mime_types = {} def find(self, *, media_range=None, name=None, mime_type=None): @@ -19,7 +19,7 @@ def find(self, *, media_range=None, name=None, mime_type=None): return self._mime_types[mime_type] if name is not None: - return self._encoders[name] + return self._codecs[name] if media_range is not None: return self._find_media_range(media_range) @@ -44,14 +44,29 @@ def register(self, encoder, names=(), mime_types=()): """Register the transcoder provided in the global list of transcoders. """ - self._encoders.update({x: encoder for x in names}) + self._codecs.update({x: encoder for x in names}) self._mime_types.update({x: encoder for x in mime_types}) - def remove(self, encoder): + def remove(self, encoder=None, name=None, mime_type=None): """Remove the encoder from the global list of available encoders. + + Attempts to match the encoder by name or mime_type if passed (but + prioritizes encoder if passed). """ - for registry in (self._encoders, self._mime_types): - for name, test in list(registry.items()): - if encoder is test: - del registry[name] + if encoder is not None: + for registry in (self._codecs, self._mime_types): + for name, test in list(registry.items()): + if encoder is test: + del registry[name] + + return + + elif name is not None: + encoder = self._codecs.get(name) + + elif mime_type is not None: + encoder = self._mime_types.get(mime_type) + + if encoder is not None: + self.remove(encoder) diff --git a/armet/decoders.py b/armet/decoders.py index a872516..41ac274 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,8 +1,8 @@ -from .codecs import CodecRegistry +from .codecs import CodecRegistry as _CodecRegistry # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +_registry = _CodecRegistry() find = _registry.find remove = _registry.remove diff --git a/armet/encoders.py b/armet/encoders.py index a872516..41ac274 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,8 +1,8 @@ -from .codecs import CodecRegistry +from .codecs import CodecRegistry as _CodecRegistry # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +_registry = _CodecRegistry() find = _registry.find remove = _registry.remove diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 479749d..6d1e92f 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -11,6 +11,7 @@ class CounterExampleEncoder: class TestCodecRegistry: + def setup(self): self.registry = CodecRegistry() self.registry.register( @@ -22,6 +23,19 @@ def setup(self): CounterExampleEncoder, mime_types=['application/xbel+xml', 'example/xml']) + def test_remove_by_object(self): + self.registry.remove(ExampleEncoder) + assert pytest.raises(KeyError, self.registry.find, name="test") + + def test_remove_by_name(self): + self.registry.remove(name="example") + assert pytest.raises(KeyError, self.registry.find, name="example") + + def test_remove_by_mime_type(self): + self.registry.remove(mime_type="example/xml") + assert pytest.raises(KeyError, self.registry.find, + mime_type="example/xml") + def test_lookup_by_mime_type(self): mime = 'test/test' assert self.registry.find(mime_type=mime) is ExampleEncoder From a0ae78e9bc6627f23d8dda241fa89fbc19698680 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 18:21:18 -0700 Subject: [PATCH 011/118] Import _version. --- armet/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/armet/__init__.py b/armet/__init__.py index e69de29..8dee4bf 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -0,0 +1 @@ +from ._version import __version__ From 5050b121a75f7bdc1f078a084e0225817982fec2 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 18:42:50 -0700 Subject: [PATCH 012/118] Add a url encoder and decoder. --- armet/__init__.py | 6 ++++++ armet/codecs.py | 37 +++++++++++++++++++++++++++++++++++++ armet/decoders.py | 33 +++++++++++++++++++++++++++++++-- armet/encoders.py | 42 ++++++++++++++++++++++++++++++++++++++++-- tests/test_encoders.py | 22 ++++++++++++++++++++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index e69de29..f28fdcc 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -0,0 +1,6 @@ +from . import encoders, decoders, codecs # flake8: noqa + + +# Register all modules in encoders and decoders +codecs.register_module(encoders, encoders.Encoder) +codecs.register_module(decoders, decoders.Decoder) diff --git a/armet/codecs.py b/armet/codecs.py index 8c5eded..5f5fa5d 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,6 +1,15 @@ import mimeparse +def register_module(module, base): + """Used to register all the codecs in a module. + Invoke using the module and the base class to test inheritance against.""" + for name in dir(module): + d = getattr(module, name) + if isinstance(d, type) and issubclass(d, base) and d is not base: + module.register(d, names=d.names, mime_types=d.mime_types) + + class CodecRegistry: """A registry used for registering and removing encoders and decoders. """ @@ -55,3 +64,31 @@ def remove(self, encoder): for name, test in list(registry.items()): if encoder is test: del registry[name] + + +class Codec: + """Generic base class for all Codec data. This is a common class used + for both the encoder and decoder.""" + + # The preferred mime-type for this encoder. This is reported in the + # Content-Type header when sending data to the client + preferred_mime_type = '' + + # The mime-types that this encoder can work with, in no particular order. + mime_types = set() + + # The common names that this encoder is known by. This is used to + # determine encoders chosen based on file extension (/api/foo.json) + names = set() + + +class URLCodec(Codec): + + preferred_mime_type = 'application/x-www-form-urlencoded' + + mime_types = { + preferred_mime_type} + + names = { + 'url', + 'urlencoded'} diff --git a/armet/decoders.py b/armet/decoders.py index a872516..73fe646 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,9 +1,38 @@ -from .codecs import CodecRegistry +from . import codecs +import urllib.parse # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +_registry = codecs.CodecRegistry() find = _registry.find remove = _registry.remove register = _registry.register + + +class Decoder: + + # The codec class for this decoder. Note that the codec must provide + # mime_types and names. + _codec = codecs.Codec + + @property + def mime_types(self): + return self._codec.mime_types + + @property + def names(self): + return self._codec.names + + def decode(self, data): + """Decode the data passed in, This function will raise a TypeError + if unable to parse or decode the data passed in.""" + raise NotImplementedError + + +class URLDecoder: + + _codec = codecs.URLCodec + + def decode(self, data): + return urllib.parse.parse_qs(data) diff --git a/armet/encoders.py b/armet/encoders.py index a872516..96e466f 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,9 +1,47 @@ -from .codecs import CodecRegistry +from . import codecs +import urllib.parse # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +# Note that all encoders defined in armet.encoders are added to the registry +# automatically. +_registry = codecs.CodecRegistry() find = _registry.find remove = _registry.remove register = _registry.register + + +class Encoder: + """The base class for armet's encoders.""" + + # The codec class for this encoder. Note that the codec must provide + # preferred_mime_type, mime_types, and names in order for this to function + # properly. + _codec = codecs.Codec + + @property + def preferred_mime_type(self): + return self._codec.preferred_mime_type + + @property + def mime_types(self): + return self._codec.mime_types + + @property + def names(self): + return self._codec.names + + def encode(self, data): + """Encode the passed data and return the encoded version. + May raise a TypeError if unable to encode the type passed in. + """ + raise NotImplementedError + + +class URLEncoder(Encoder): + + _codec = codecs.URLCodec + + def encode(self, data): + return urllib.parse.urlencode(data) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 726219a..89d392e 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -1,7 +1,29 @@ from armet import encoders +from collections import OrderedDict +import pytest def test_encoders_api_methods(): assert encoders.find assert encoders.register assert encoders.remove + + +class TestURLEncoder: + + def setup(self): + self.encoder = encoders.find(name='url')() + + def test_encode_normal(self): + data = OrderedDict( + foo='bar', + bar='baz', + fiz='buzz') + + expected = 'fiz=buzz&foo=bar&bar=baz' + + assert self.encoder.encode(data) == expected + + def test_unable_to_encode(self): + with pytest.raises(TypeError): + self.encoder.encode([{'foo': 'bar'}]) From 1e293828613da1782c4ee92d14e6fa22bca46c91 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 18:48:57 -0700 Subject: [PATCH 013/118] Don't expose all the things --- armet/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/armet/__init__.py b/armet/__init__.py index 9d450d1..7430635 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -5,3 +5,10 @@ # Register all modules in encoders and decoders codecs.register_module(encoders, encoders.Encoder) codecs.register_module(decoders, decoders.Decoder) + + +__all__ = [ + __version__, + encoders, + decoders +] From 8cf00811427ba41ad41078866935b20cf055d67f Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 19:13:02 -0700 Subject: [PATCH 014/118] Fix import errors and test errors. --- armet/decoders.py | 6 +++--- armet/encoders.py | 8 ++++---- armet/utils.py | 11 +++++++++++ tests/test_encoders.py | 10 +++++----- 4 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 armet/utils.py diff --git a/armet/decoders.py b/armet/decoders.py index 73fe646..7d833c6 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,4 +1,4 @@ -from . import codecs +from . import codecs, utils import urllib.parse @@ -16,11 +16,11 @@ class Decoder: # mime_types and names. _codec = codecs.Codec - @property + @utils.classproperty def mime_types(self): return self._codec.mime_types - @property + @utils.classproperty def names(self): return self._codec.names diff --git a/armet/encoders.py b/armet/encoders.py index 96e466f..e95e36e 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,4 +1,4 @@ -from . import codecs +from . import codecs, utils import urllib.parse @@ -20,15 +20,15 @@ class Encoder: # properly. _codec = codecs.Codec - @property + @utils.classproperty def preferred_mime_type(self): return self._codec.preferred_mime_type - @property + @utils.classproperty def mime_types(self): return self._codec.mime_types - @property + @utils.classproperty def names(self): return self._codec.names diff --git a/armet/utils.py b/armet/utils.py new file mode 100644 index 0000000..6998509 --- /dev/null +++ b/armet/utils.py @@ -0,0 +1,11 @@ + + +class classproperty(object): + """Declares a read-only `property` that acts on the class object. + """ + + def __init__(self, getter): + self.getter = getter + + def __get__(self, obj, cls): + return self.getter(cls) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 89d392e..8c456ca 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -15,12 +15,12 @@ def setup(self): self.encoder = encoders.find(name='url')() def test_encode_normal(self): - data = OrderedDict( - foo='bar', - bar='baz', - fiz='buzz') + data = OrderedDict(( + ('foo', 'bar'), + ('bar', 'baz'), + ('fiz', 'buzz'))) - expected = 'fiz=buzz&foo=bar&bar=baz' + expected = 'foo=bar&bar=baz&fiz=buzz' assert self.encoder.encode(data) == expected From f56fdc60751acecad03769ada0dab555ef431df3 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 1 Jul 2014 19:27:43 -0700 Subject: [PATCH 015/118] Fix issue interfacing with the mimeparse library, and add URLDecoder tests. --- armet/codecs.py | 6 +++--- armet/decoders.py | 10 +++++----- armet/encoders.py | 14 +++++++------- tests/test_decoders.py | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/armet/codecs.py b/armet/codecs.py index 14fe89d..88aba75 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -39,14 +39,14 @@ def find(self, *, media_range=None, name=None, mime_type=None): def _find_media_range(self, media_range): try: + # best_match returns empty string on failure to find a match. + # The correct KeyError will be thrown when attempting the lookup + # below. found = mimeparse.best_match(self._mime_types.keys(), media_range) except ValueError: raise KeyError('Malformed media range.') - if found is None: - raise KeyError - return self._mime_types[found] def register(self, encoder, names=(), mime_types=()): diff --git a/armet/decoders.py b/armet/decoders.py index 7d833c6..5312759 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -17,12 +17,12 @@ class Decoder: _codec = codecs.Codec @utils.classproperty - def mime_types(self): - return self._codec.mime_types + def mime_types(cls): + return cls._codec.mime_types @utils.classproperty - def names(self): - return self._codec.names + def names(cls): + return cls._codec.names def decode(self, data): """Decode the data passed in, This function will raise a TypeError @@ -30,7 +30,7 @@ def decode(self, data): raise NotImplementedError -class URLDecoder: +class URLDecoder(Decoder): _codec = codecs.URLCodec diff --git a/armet/encoders.py b/armet/encoders.py index e95e36e..9c3e17a 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -18,19 +18,19 @@ class Encoder: # The codec class for this encoder. Note that the codec must provide # preferred_mime_type, mime_types, and names in order for this to function # properly. - _codec = codecs.Codec + _codec = None @utils.classproperty - def preferred_mime_type(self): - return self._codec.preferred_mime_type + def preferred_mime_type(cls): + return cls._codec.preferred_mime_type @utils.classproperty - def mime_types(self): - return self._codec.mime_types + def mime_types(cls): + return cls._codec.mime_types @utils.classproperty - def names(self): - return self._codec.names + def names(cls): + return cls._codec.names def encode(self, data): """Encode the passed data and return the encoded version. diff --git a/tests/test_decoders.py b/tests/test_decoders.py index af8f518..3fcf913 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -1,7 +1,28 @@ from armet import decoders +import pytest def test_decoders_api_methods(): assert decoders.find assert decoders.register assert decoders.remove + + +class TestURLDecoder: + + def setup(self): + self.decoder = decoders.find(name='url')() + + def test_decode_normal(self): + data = 'foo=bar&bar=baz&fiz=buzz' + + expected = { + 'foo': 'bar', + 'bar': 'baz', + 'fiz': 'buzz'} + + assert self.decoder.decode(data) == expected + + def test_unable_to_decode(self): + with pytest.raises(TypeError): + self.decoder.decode([{'foo': 'bar'}]) From abbb84dd2bba4196954fe37c1831b3da41ec4635 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 1 Jul 2014 19:35:16 -0700 Subject: [PATCH 016/118] Remove assert for pytest.raises --- tests/test_codecs.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 6d1e92f..aa559a2 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -25,16 +25,15 @@ def setup(self): def test_remove_by_object(self): self.registry.remove(ExampleEncoder) - assert pytest.raises(KeyError, self.registry.find, name="test") + pytest.raises(KeyError, self.registry.find, name="test") def test_remove_by_name(self): self.registry.remove(name="example") - assert pytest.raises(KeyError, self.registry.find, name="example") + pytest.raises(KeyError, self.registry.find, name="example") def test_remove_by_mime_type(self): self.registry.remove(mime_type="example/xml") - assert pytest.raises(KeyError, self.registry.find, - mime_type="example/xml") + pytest.raises(KeyError, self.registry.find, mime_type="example/xml") def test_lookup_by_mime_type(self): mime = 'test/test' @@ -59,9 +58,9 @@ def test_lookup_by_name(self): assert self.registry.find(name='example') is ExampleEncoder def test_lookup_failure(self): - assert pytest.raises(KeyError, self.registry.find, name='missing') + pytest.raises(KeyError, self.registry.find, name='missing') mime = 'application/missing;q=0.5' - assert pytest.raises(KeyError, self.registry.find, media_range=mime) + pytest.raises(KeyError, self.registry.find, media_range=mime) def test_lookup_bad_args(self): with pytest.raises(TypeError): From d19781d91d08697b753240c3f5ae549b07fd9e13 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 2 Jul 2014 11:36:33 -0700 Subject: [PATCH 017/118] Various changes after review. - URL encoder / decoder is now reversable - Made registration process simpler --- armet/__init__.py | 19 ++++++++++------ armet/codecs.py | 49 +++++++----------------------------------- armet/decoders.py | 36 +++++++++---------------------- armet/encoders.py | 48 +++++++++++------------------------------ tests/test_decoders.py | 8 +++---- tests/test_encoders.py | 6 +++--- 6 files changed, 50 insertions(+), 116 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 7430635..ed0c7dd 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,14 +1,19 @@ from ._version import __version__ -from . import encoders, decoders, codecs # flake8: noqa - - -# Register all modules in encoders and decoders -codecs.register_module(encoders, encoders.Encoder) -codecs.register_module(decoders, decoders.Decoder) - +from . import encoders, decoders, codecs __all__ = [ __version__, encoders, decoders ] + + +# Register each encoder. +encoders.register(encoders.URLEncoder.encode, + names=codecs.URLCodec.names, + mime_types=codecs.URLCodec.mime_types) + +# Register each decoder. +decoders.register(decoders.URLDecoder.decode, + names=codecs.URLCodec.names, + mime_types=codecs.URLCodec.mime_types) diff --git a/armet/codecs.py b/armet/codecs.py index 88aba75..adeeade 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,15 +1,6 @@ import mimeparse -def register_module(module, base): - """Used to register all the codecs in a module. - Invoke using the module and the base class to test inheritance against.""" - for name in dir(module): - d = getattr(module, name) - if isinstance(d, type) and issubclass(d, base) and d is not base: - module.register(d, names=d.names, mime_types=d.mime_types) - - class CodecRegistry: """A registry used for registering and removing encoders and decoders. """ @@ -56,7 +47,7 @@ def register(self, encoder, names=(), mime_types=()): self._codecs.update({x: encoder for x in names}) self._mime_types.update({x: encoder for x in mime_types}) - def remove(self, encoder=None, name=None, mime_type=None): + def remove(self, encoder=None, *, name=None, mime_type=None): """Remove the encoder from the global list of available encoders. Attempts to match the encoder by name or mime_type if passed (but @@ -69,41 +60,17 @@ def remove(self, encoder=None, name=None, mime_type=None): if encoder is test: del registry[name] - return - - elif name is not None: - encoder = self._codecs.get(name) - - elif mime_type is not None: - encoder = self._mime_types.get(mime_type) - - if encoder is not None: - self.remove(encoder) - - -class Codec: - """Generic base class for all Codec data. This is a common class used - for both the encoder and decoder.""" - - # The preferred mime-type for this encoder. This is reported in the - # Content-Type header when sending data to the client - preferred_mime_type = '' - - # The mime-types that this encoder can work with, in no particular order. - mime_types = set() + if name is not None: + self.remove(self._codecs.get(name)) - # The common names that this encoder is known by. This is used to - # determine encoders chosen based on file extension (/api/foo.json) - names = set() + if mime_type is not None: + self.remove(self._mime_types.get(mime_type)) -class URLCodec(Codec): +class URLCodec: preferred_mime_type = 'application/x-www-form-urlencoded' - mime_types = { - preferred_mime_type} + mime_types = {preferred_mime_type} - names = { - 'url', - 'urlencoded'} + names = {'url'} diff --git a/armet/decoders.py b/armet/decoders.py index 5312759..9748bfa 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,38 +1,22 @@ -from . import codecs, utils +from .codecs import CodecRegistry import urllib.parse # Create our encoder registry and pull methods off it for easy access. -_registry = codecs.CodecRegistry() +_registry = CodecRegistry() find = _registry.find remove = _registry.remove register = _registry.register -class Decoder: +class URLDecoder: - # The codec class for this decoder. Note that the codec must provide - # mime_types and names. - _codec = codecs.Codec + @classmethod + def decode(cls, text): + try: + data = urllib.parse.parse_qs(text) + return {k: v[0] if len(v) == 1 else v for k, v in data.items()} - @utils.classproperty - def mime_types(cls): - return cls._codec.mime_types - - @utils.classproperty - def names(cls): - return cls._codec.names - - def decode(self, data): - """Decode the data passed in, This function will raise a TypeError - if unable to parse or decode the data passed in.""" - raise NotImplementedError - - -class URLDecoder(Decoder): - - _codec = codecs.URLCodec - - def decode(self, data): - return urllib.parse.parse_qs(data) + except AttributeError as ex: + raise TypeError from ex diff --git a/armet/encoders.py b/armet/encoders.py index 9c3e17a..216150f 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,47 +1,25 @@ -from . import codecs, utils +from .codecs import CodecRegistry +from itertools import chain, repeat +from urllib.parse import urlencode import urllib.parse # Create our encoder registry and pull methods off it for easy access. -# Note that all encoders defined in armet.encoders are added to the registry -# automatically. -_registry = codecs.CodecRegistry() +_registry = CodecRegistry() find = _registry.find remove = _registry.remove register = _registry.register -class Encoder: - """The base class for armet's encoders.""" +class URLEncoder: - # The codec class for this encoder. Note that the codec must provide - # preferred_mime_type, mime_types, and names in order for this to function - # properly. - _codec = None + @classmethod + def encode(cls, data): + try: + return urlencode(list(chain.from_iterable( + ((k, v),) if isinstance(v, str) else zip(repeat(k), v) + for k, v in data.items()))) - @utils.classproperty - def preferred_mime_type(cls): - return cls._codec.preferred_mime_type - - @utils.classproperty - def mime_types(cls): - return cls._codec.mime_types - - @utils.classproperty - def names(cls): - return cls._codec.names - - def encode(self, data): - """Encode the passed data and return the encoded version. - May raise a TypeError if unable to encode the type passed in. - """ - raise NotImplementedError - - -class URLEncoder(Encoder): - - _codec = codecs.URLCodec - - def encode(self, data): - return urllib.parse.urlencode(data) + except AttributeError as ex: + raise TypeError from ex diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 3fcf913..c3c2cbc 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -11,7 +11,7 @@ def test_decoders_api_methods(): class TestURLDecoder: def setup(self): - self.decoder = decoders.find(name='url')() + self.decode = decoders.find(name='url') def test_decode_normal(self): data = 'foo=bar&bar=baz&fiz=buzz' @@ -21,8 +21,8 @@ def test_decode_normal(self): 'bar': 'baz', 'fiz': 'buzz'} - assert self.decoder.decode(data) == expected + assert self.decode(data) == expected - def test_unable_to_decode(self): + def test_decode_object(self): with pytest.raises(TypeError): - self.decoder.decode([{'foo': 'bar'}]) + self.decode([{'foo': 'bar'}]) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 8c456ca..8f7131b 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -12,7 +12,7 @@ def test_encoders_api_methods(): class TestURLEncoder: def setup(self): - self.encoder = encoders.find(name='url')() + self.encode = encoders.find(name='url') def test_encode_normal(self): data = OrderedDict(( @@ -22,8 +22,8 @@ def test_encode_normal(self): expected = 'foo=bar&bar=baz&fiz=buzz' - assert self.encoder.encode(data) == expected + assert self.encode(data) == expected def test_unable_to_encode(self): with pytest.raises(TypeError): - self.encoder.encode([{'foo': 'bar'}]) + self.encode([{'foo': 'bar'}]) From b79c86c8ea9f0c137ad2ef2c9679fd0aa08f7649 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 2 Jul 2014 11:37:38 -0700 Subject: [PATCH 018/118] Removed now unused classproperty --- armet/utils.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 armet/utils.py diff --git a/armet/utils.py b/armet/utils.py deleted file mode 100644 index 6998509..0000000 --- a/armet/utils.py +++ /dev/null @@ -1,11 +0,0 @@ - - -class classproperty(object): - """Declares a read-only `property` that acts on the class object. - """ - - def __init__(self, getter): - self.getter = getter - - def __get__(self, obj, cls): - return self.getter(cls) From bfaab998f5ca7cb458263aaff81983f52037739f Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 2 Jul 2014 11:57:02 -0700 Subject: [PATCH 019/118] s/encoder/codec --- armet/codecs.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/armet/codecs.py b/armet/codecs.py index adeeade..8a11e14 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -11,8 +11,8 @@ def __init__(self): def find(self, *, media_range=None, name=None, mime_type=None): """ - Attempt to find a compliant encoder given either a mime_type or - encoder name. Prioritize the mime-type, then name, then media_range. + Attempt to find a compliant codec given either a mime_type or + codec name. Prioritize the mime-type, then name, then media_range. """ if mime_type is not None: @@ -40,24 +40,24 @@ def _find_media_range(self, media_range): return self._mime_types[found] - def register(self, encoder, names=(), mime_types=()): + def register(self, codec, names=(), mime_types=()): """Register the transcoder provided in the global list of transcoders. """ - self._codecs.update({x: encoder for x in names}) - self._mime_types.update({x: encoder for x in mime_types}) + self._codecs.update({x: codec for x in names}) + self._mime_types.update({x: codec for x in mime_types}) - def remove(self, encoder=None, *, name=None, mime_type=None): - """Remove the encoder from the global list of available encoders. + def remove(self, codec=None, *, name=None, mime_type=None): + """Remove the codec from the global list of available codecs. - Attempts to match the encoder by name or mime_type if passed (but - prioritizes encoder if passed). + Attempts to match the codec by name or mime_type if passed (but + prioritizes codec if passed). """ - if encoder is not None: + if codec is not None: for registry in (self._codecs, self._mime_types): for name, test in list(registry.items()): - if encoder is test: + if codec is test: del registry[name] if name is not None: From 1d23be42c3b106f318bd449193bd21fa3fc0cd88 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 2 Jul 2014 13:06:40 -0700 Subject: [PATCH 020/118] Add json encoder and decoder. --- armet/__init__.py | 12 ++++++++++++ armet/codecs.py | 23 +++++++++++++++++++++++ armet/decoders.py | 11 +++++++++++ armet/encoders.py | 13 ++++++++++++- tests/test_decoders.py | 19 +++++++++++++++++++ tests/test_encoders.py | 21 +++++++++++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) diff --git a/armet/__init__.py b/armet/__init__.py index ed0c7dd..f288d19 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -9,11 +9,23 @@ # Register each encoder. +# URL Encoder encoders.register(encoders.URLEncoder.encode, names=codecs.URLCodec.names, mime_types=codecs.URLCodec.mime_types) +# JSON Encoder +encoders.register(encoders.JSONEncoder.encode, + names=codecs.JSONCodec.names, + mime_types=codecs.JSONCodec.mime_types) + # Register each decoder. +# URL Decoder decoders.register(decoders.URLDecoder.decode, names=codecs.URLCodec.names, mime_types=codecs.URLCodec.mime_types) + +# JSON Decoder +decoders.register(decoders.JSONDecoder.decode, + names=codecs.JSONCodec.names, + mime_types=codecs.JSONCodec.mime_types) diff --git a/armet/codecs.py b/armet/codecs.py index adeeade..3b1e5c4 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -74,3 +74,26 @@ class URLCodec: mime_types = {preferred_mime_type} names = {'url'} + + +class JSONCodec: + + preferred_mime_type = 'application/json' + + mime_types = { + preferred_mime_type, + # Offical; as per RFC 4627. + 'application/json', + + # Widely used (thanks .) + 'application/jsonrequest', + + # Miscellaneous mimetypes that are used frequently (incorrectly). + 'application/x-json', + 'text/json', + + # Widely used (incorrectly) thanks to ruby. + 'text/x-json', + } + + names = {'json'} diff --git a/armet/decoders.py b/armet/decoders.py index 9748bfa..d910ead 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,5 +1,6 @@ from .codecs import CodecRegistry import urllib.parse +import json # Create our encoder registry and pull methods off it for easy access. @@ -20,3 +21,13 @@ def decode(cls, text): except AttributeError as ex: raise TypeError from ex + + +class JSONDecoder: + + @classmethod + def decode(cls, text): + try: + return json.loads(text) + except ValueError as ex: + raise TypeError from ex diff --git a/armet/encoders.py b/armet/encoders.py index 216150f..e176f61 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,7 +1,7 @@ from .codecs import CodecRegistry from itertools import chain, repeat from urllib.parse import urlencode -import urllib.parse +import json # Create our encoder registry and pull methods off it for easy access. @@ -17,9 +17,20 @@ class URLEncoder: @classmethod def encode(cls, data): try: + # Normalize the encode so that users pay invoke using either + # {"foo": "bar"} or {"foo": ["bar", "baz"]}. return urlencode(list(chain.from_iterable( ((k, v),) if isinstance(v, str) else zip(repeat(k), v) for k, v in data.items()))) except AttributeError as ex: raise TypeError from ex + + +class JSONEncoder: + + @classmethod + def encode(cls, data): + # Separators are used here to assert that no uneccesary spaces are + # added to the json. + return json.dumps(data, separators=(',', ':')) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index c3c2cbc..2292821 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -1,5 +1,6 @@ from armet import decoders import pytest +import json def test_decoders_api_methods(): @@ -26,3 +27,21 @@ def test_decode_normal(self): def test_decode_object(self): with pytest.raises(TypeError): self.decode([{'foo': 'bar'}]) + + +class TestJSONDecoder: + def setup(self): + self.decode = decoders.find(name='json') + + def test_encode_normal(self): + data = { + 'foo': 5, + 'bar': None, + 'baz': ['a', 'b', 'c'], + 'bang': {'buzz': 'boop'}} + + assert self.decode(json.dumps(data)) == data + + def test_encode_failure(self): + with pytest.raises(TypeError): + self.decode('fail') diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 8f7131b..67e0c20 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -27,3 +27,24 @@ def test_encode_normal(self): def test_unable_to_encode(self): with pytest.raises(TypeError): self.encode([{'foo': 'bar'}]) + + +class TestJSONEncoder: + def setup(self): + self.encode = encoders.find(name='json') + + def test_encode_normal(self): + data = OrderedDict([ + ('foo', 5), + ('bar', None), + ('baz', ['a', 'b', 'c']), + ('bang', {'buzz': 'boop'})]) + + expected = ('{"foo":5,"bar":null,"baz":["a","b","c"],' + '"bang":{"buzz":"boop"}}') + + assert self.encode(data) == expected + + def test_encode_failure(self): + with pytest.raises(TypeError): + self.encode({'foo': range(10)}) From 6c0312d88d46b7da56b90407b8579bfdd421c6da Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 2 Jul 2014 17:50:10 -0700 Subject: [PATCH 021/118] Add multipart/form-data encoder --- armet/__init__.py | 5 ++ armet/codecs.py | 9 +++ armet/encoders/__init__.py | 12 ++++ armet/encoders/form.py | 80 ++++++++++++++++++++++ armet/{encoders.py => encoders/general.py} | 2 +- tests/test_encoders.py | 45 ++++++++++++ 6 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 armet/encoders/__init__.py create mode 100644 armet/encoders/form.py rename armet/{encoders.py => encoders/general.py} (96%) diff --git a/armet/__init__.py b/armet/__init__.py index f288d19..5832cc4 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -19,6 +19,11 @@ names=codecs.JSONCodec.names, mime_types=codecs.JSONCodec.mime_types) +# FormData encoder +encoders.register(encoders.FormDataEncoder, + names=codecs.FormDataCodec.names, + mime_types=codecs.FormDataCodec.mime_types) + # Register each decoder. # URL Decoder decoders.register(decoders.URLDecoder.decode, diff --git a/armet/codecs.py b/armet/codecs.py index 54c9f7f..a1e390d 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -97,3 +97,12 @@ class JSONCodec: } names = {'json'} + + +class FormDataCodec: + + preferred_mime_type = 'multipart/form-data' + + mime_types = {preferred_mime_type} + + names = {'form'} diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py new file mode 100644 index 0000000..2eeeb61 --- /dev/null +++ b/armet/encoders/__init__.py @@ -0,0 +1,12 @@ +from .general import register, remove, find, URLEncoder, JSONEncoder +from .form import form_data as FormDataEncoder + + +__all__ = [ + register, + remove, + find, + URLEncoder, + JSONEncoder, + FormDataEncoder, +] diff --git a/armet/encoders/form.py b/armet/encoders/form.py new file mode 100644 index 0000000..4bce53d --- /dev/null +++ b/armet/encoders/form.py @@ -0,0 +1,80 @@ +import uuid +from io import BytesIO +import collections + + +def generate_boundary(): + """http://xkcd.com/221/""" + return uuid.uuid4().hex + + +def generate_encoder(encoding): + def retfn(string): + return string.encode(encoding) + return retfn + + +# Form data header sample: +# Content-Type: multipart/form-data; boundary=AaB03x + +# The form data encoder should spit out something like this: +# --AaB03x +# Content-Disposition: form-data; name="submit-name" +# +# Larry +# --AaB03x +# Content-Disposition: form-data; name="files"; filename="file1.txt" +# Content-Type: text/plain +# +# ... contents of file1.txt ... +# --AaB03x-- + + +def form_data(data, encoding='utf-8'): + """Expects to recieve a data structure of the following form: + {name: value, name: [value1, value2]} + """ + + buf = BytesIO() + encode = generate_encoder(encoding) + boundary = encode(generate_boundary()) + + # This function uses a lot of + to join strings, + # this is because python3 does not allow for b'%s' % bytes() + + # Sanity check. + if not isinstance(data, collections.Mapping): + raise TypeError + + for name, values in data.items(): + # Normalize the values. + if isinstance(values, str): + values = (values,) + elif not isinstance(values, collections.Sequence): + # Sanity check. + raise TypeError + + for entry in values: + # Write the boundary for the next entry. + buf.write(b'--' + boundary + b'\r\n') + + # Write the header for the next entry. + # Note the extra new line afterwards. This is because the next + # entry is the value. + bounds = ( + b'Content-Disposition: form-data; name=' + + encode(name) + + b'\r\n\r\n') + + buf.write(bounds) + + # Write the contents. + buf.write(encode(entry)) + + # Write the trailing newline + buf.write(b'\r\n') + + # All done. + buf.write(b'--' + boundary + b'--') + + return buf.getvalue() diff --git a/armet/encoders.py b/armet/encoders/general.py similarity index 96% rename from armet/encoders.py rename to armet/encoders/general.py index e176f61..c67654a 100644 --- a/armet/encoders.py +++ b/armet/encoders/general.py @@ -1,4 +1,4 @@ -from .codecs import CodecRegistry +from armet.codecs import CodecRegistry from itertools import chain, repeat from urllib.parse import urlencode import json diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 67e0c20..1ef8d96 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -1,6 +1,7 @@ from armet import encoders from collections import OrderedDict import pytest +from unittest import mock def test_encoders_api_methods(): @@ -48,3 +49,47 @@ def test_encode_normal(self): def test_encode_failure(self): with pytest.raises(TypeError): self.encode({'foo': range(10)}) + + +class TestFormDataEncoder: + def setup(self): + self.encode = encoders.find(name='form') + + @mock.patch('armet.encoders.form.generate_boundary') + def test_encode_normal(self, mocked): + # Assert that the mocked function always returns the same value. + mocked.return_value = 'abc123' + + data = OrderedDict(( + ('foo', 'bar'), + ('bar', 'baz'), + ('fiz', ['buzz', 'bang']))) + + expected = ( + b'--abc123\r\n' + b'Content-Disposition: form-data; name=foo\r\n' + b'\r\n' + b'bar\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=bar\r\n' + b'\r\n' + b'baz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'buzz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'bang\r\n' + b'--abc123--' + ) + + assert self.encode(data) == expected + + def test_encode_failure(self): + with pytest.raises(TypeError): + self.encode([{'a': 'b'}]) + + with pytest.raises(TypeError): + self.encode({'a': {'b': 'c'}}) From 232ed5afaf44285ac7b0366226eab7287dc031a5 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 2 Jul 2014 21:58:01 -0700 Subject: [PATCH 022/118] Initial stab at putting resource routing in there. --- armet/app.py | 60 ++++++++++++++++++++++++++++++++++++++++++ armet/encoders.py | 7 +++++ armet/http.py | 19 +++++++++++++ armet/resources.py | 25 ++++++++++++++++++ setup.py | 2 ++ tests/test_decoders.py | 8 ++++-- tests/test_encoders.py | 21 +++++++++++++++ 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 armet/app.py create mode 100644 armet/http.py create mode 100644 armet/resources.py diff --git a/armet/app.py b/armet/app.py new file mode 100644 index 0000000..3fadbff --- /dev/null +++ b/armet/app.py @@ -0,0 +1,60 @@ +from . import resources, http, encoders + + +def application(environ, start_response): + # Create the request wrapper around the environ (to make this at + # least half-way sane). + request = http.Request(environ) + + # Return an empty 404 if were accessed at "/" (we don't handle this yet). + if request.path == "/": + start_response("404 Not Found", []) + return [b""] + + try: + # Attempt to find the resource through the initial path. + resource_cls = resources._registry[request.path[1:]] + + except KeyError: + # Return a 404; we don't know what the resource is. + start_response("404 Not Found", []) + return [b""] + + # Instantiate the resource class. + resource = resource_cls() + + # Route the resource appropriately. + try: + route = getattr(resource, request.method.lower()) + except AttributeError: + try: + route = resource.route + except AttributeError: + # Method is not allowed on the resource. + start_response("405 Method Not Allowed", []) + return [b""] + + # Dispatch the request. + data = route(request) + + # Find an available encoder. + content_type = request.headers.get("Accept", "application/json") + if content_type == "*/*": + content_type = "application/json" + + try: + encode = encoders.find(media_range=content_type) + + except KeyError: + # Failed to find a matching encoder. + start_response("406 Not Acceptable", []) + return [b""] + + # Encode the data. + text = encode(data) + + # Return a successful response. + # TODO: We need a way to know the content-type here.. or the encoder + # needs to handle pushing the content-type. + start_response("202 Ok", [("Content-Type", "application/json")]) + return [text.encode("utf-8")] diff --git a/armet/encoders.py b/armet/encoders.py index e176f61..a11cb18 100644 --- a/armet/encoders.py +++ b/armet/encoders.py @@ -1,6 +1,7 @@ from .codecs import CodecRegistry from itertools import chain, repeat from urllib.parse import urlencode +from collections import Iterable import json @@ -31,6 +32,12 @@ class JSONEncoder: @classmethod def encode(cls, data): + # Ensure that the scalar data is wrapped in a list as + # a valid JSON document must be an object or a list. + # See: http://tools.ietf.org/html/rfc4627 + if not isinstance(data, str) and not isinstance(data, Iterable): + data = [data] + # Separators are used here to assert that no uneccesary spaces are # added to the json. return json.dumps(data, separators=(',', ':')) diff --git a/armet/http.py b/armet/http.py new file mode 100644 index 0000000..0b41ddd --- /dev/null +++ b/armet/http.py @@ -0,0 +1,19 @@ +from werkzeug.wrappers import BaseRequest + + +class Request: + + def __init__(self, environ): + self._handle = BaseRequest(environ, populate_request=False) + + @property + def method(self): + return self._handle.method + + @property + def path(self): + return self._handle.path + + @property + def headers(self): + return self._handle.headers diff --git a/armet/resources.py b/armet/resources.py new file mode 100644 index 0000000..307c72f --- /dev/null +++ b/armet/resources.py @@ -0,0 +1,25 @@ + +# NOTE: Should this that `Registry` thing we were talking about? +_registry = {} + + +def resource(handler=None, **kwargs): + + def decorator(handler): + # Register the handler according to the name. + name = kwargs.get("name") or handler.__name__.lower() + if isinstance(handler, type): + _registry[name] = handler + + else: + _registry[name] = type(name, (), { + "route": lambda self, request: handler(request) + }) + + # Return the original function, unmodified. + return handler + + if handler is None: + return decorator + + return decorator(handler) diff --git a/setup.py b/setup.py index 952aac7..530606d 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,8 @@ 'pytest', 'pytest-pep8', 'pytest-cov', + 'pytest-bench', 'python-mimeparse', + 'werkzeug' ], ) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 2292821..120e614 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -1,5 +1,6 @@ from armet import decoders import pytest +from pytest import mark import json @@ -9,6 +10,7 @@ def test_decoders_api_methods(): assert decoders.remove +@mark.bench('self.decode', iterations=10000) class TestURLDecoder: def setup(self): @@ -29,11 +31,13 @@ def test_decode_object(self): self.decode([{'foo': 'bar'}]) +@mark.bench('self.decode', iterations=10000) class TestJSONDecoder: + def setup(self): self.decode = decoders.find(name='json') - def test_encode_normal(self): + def test_decode_normal(self): data = { 'foo': 5, 'bar': None, @@ -42,6 +46,6 @@ def test_encode_normal(self): assert self.decode(json.dumps(data)) == data - def test_encode_failure(self): + def test_decode_failure(self): with pytest.raises(TypeError): self.decode('fail') diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 67e0c20..7ef9f97 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -1,6 +1,8 @@ from armet import encoders +import json from collections import OrderedDict import pytest +from pytest import mark def test_encoders_api_methods(): @@ -9,6 +11,7 @@ def test_encoders_api_methods(): assert encoders.remove +@mark.bench('self.encode', iterations=10000) class TestURLEncoder: def setup(self): @@ -29,10 +32,28 @@ def test_unable_to_encode(self): self.encode([{'foo': 'bar'}]) +@mark.bench('self.encode', iterations=10000) class TestJSONEncoder: + def setup(self): self.encode = encoders.find(name='json') + def test_encode_scalar(self): + data = False + + assert self.encode(data) == "[false]" + + def test_encode_null(self): + data = None + + assert self.encode(data) == "[null]" + + def test_encode_large_simple_list(self): + data = list(range(1, 10000)) + + text = self.encode(data) + assert json.loads(text) == data + def test_encode_normal(self): data = OrderedDict([ ('foo', 5), From 42c023e67cb3e94adf791df10db37236af823702 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 01:06:53 -0700 Subject: [PATCH 023/118] Fix grammar here. --- armet/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armet/resources.py b/armet/resources.py index 307c72f..da01063 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -1,5 +1,5 @@ -# NOTE: Should this that `Registry` thing we were talking about? +# NOTE: Should this be that `Registry` thing we were talking about? _registry = {} From 6e8ac7eede2b9c1ea1495cd25628b4a79a68e97d Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 01:47:17 -0700 Subject: [PATCH 024/118] Re-orient the routing from an API (hub) centric perspective. --- armet/api.py | 124 +++++++++++++++++++++++++++++++++++++++++++++ armet/app.py | 60 ---------------------- armet/resources.py | 25 --------- 3 files changed, 124 insertions(+), 85 deletions(-) create mode 100644 armet/api.py delete mode 100644 armet/app.py delete mode 100644 armet/resources.py diff --git a/armet/api.py b/armet/api.py new file mode 100644 index 0000000..df6db28 --- /dev/null +++ b/armet/api.py @@ -0,0 +1,124 @@ +from . import decoders, encoders, http +import re + + +def _dasherize(text): + # TODO: This could probably be optimized significantly. + text = text.strip() + text = re.sub(r'([A-Z])', r'-\1', text) + text = re.sub(r'[-_\s]+', r'-', text) + text = re.sub(r'^-', r'', text) + text = text.lower() + return text.strip() + + +class Api: + + def __init__(self): + # TODO: Should this be that `Registry` thing we were talking about? + # That would give us the `remove` functionality easily + self._registry = {} + + def register(self, handler, *, expose=True, name=None): + # Discern the name of the handler in order to register it. + if name is None: + # Convert the name of the handler to dash-case + name = _dasherize(handler.__name__) + + # Strip a trailing '-resource' from it + name = re.sub(r'-resource$', '', name) + + # Insert the handler into the registry. + self._registry[name] = handler + + def __call__(self, environ, start_response): + # TODO: Figure out how the response wrapper works and use it + # TODO: We need a way to know the content-type here.. or the encoder + # needs to handle pushing the content-type. + + # Create the request wrapper around the environ (to make this at + # least half-way sane). + request = http.Request(environ) + + # Return an empty 404 if were accessed at "/" ( + # we don't handle this yet). + if request.path == "/": + start_response("404 Not Found", []) + return [b""] + + try: + # Attempt to find the resource through the initial path. + resource_cls = self._registry[request.path[1:]] + + except KeyError: + # Return a 404; we don't know what the resource is. + start_response("404 Not Found", []) + return [b""] + + # Instantiate the resource class. + resource = resource_cls(request=request) + + # Route the resource appropriately. + try: + route = getattr(resource, request.method.lower()) + + except AttributeError: + try: + route = resource.route + + except AttributeError: + # Method is not allowed on the resource. + start_response("405 Method Not Allowed", []) + return [b""] + + # Read in the request data. + # TODO: Think of a way to expose this (just "content" or "data") + request_raw_data = request._handle.data + request_data = None + if request_raw_data: + # Find an available decoder. + content_type = request.headers.get("Content-Type") + if not content_type: + # TODO: Handle content-type "detection" + start_response("415 Unsupported Media Type", []) + return [b""] + + try: + # TODO: The content-type header could state more than just + # the mime_type (charset, etc.); think of how to deal + # with it as media_range detection "works" with it but + # mime_type would be faster. + decode = decoders.find(media_range=content_type) + + except KeyError: + # Failed to find a matching encoder. + start_response("415 Unsupported Media Type", []) + return [b""] + + # Decode the incoming request data. + # TODO: We should likely be sending the proper charset + # to ".decode(..)" + request_data = decode(request_raw_data.decode("utf-8")) + + # Dispatch the request. + response_data = route(request_data) + + # Find an available encoder. + media_range = request.headers.get("Accept", "application/json") + if media_range == "*/*": + media_range = "application/json" + + try: + encode = encoders.find(media_range=media_range) + + except KeyError: + # Failed to find a matching encoder. + start_response("406 Not Acceptable", []) + return [b""] + + # Encode the data. + response_text = encode(response_data) + + # Return a successful response. + start_response("200 Ok", [("Content-Type", "application/json")]) + return [response_text.encode("utf-8")] diff --git a/armet/app.py b/armet/app.py deleted file mode 100644 index 3fadbff..0000000 --- a/armet/app.py +++ /dev/null @@ -1,60 +0,0 @@ -from . import resources, http, encoders - - -def application(environ, start_response): - # Create the request wrapper around the environ (to make this at - # least half-way sane). - request = http.Request(environ) - - # Return an empty 404 if were accessed at "/" (we don't handle this yet). - if request.path == "/": - start_response("404 Not Found", []) - return [b""] - - try: - # Attempt to find the resource through the initial path. - resource_cls = resources._registry[request.path[1:]] - - except KeyError: - # Return a 404; we don't know what the resource is. - start_response("404 Not Found", []) - return [b""] - - # Instantiate the resource class. - resource = resource_cls() - - # Route the resource appropriately. - try: - route = getattr(resource, request.method.lower()) - except AttributeError: - try: - route = resource.route - except AttributeError: - # Method is not allowed on the resource. - start_response("405 Method Not Allowed", []) - return [b""] - - # Dispatch the request. - data = route(request) - - # Find an available encoder. - content_type = request.headers.get("Accept", "application/json") - if content_type == "*/*": - content_type = "application/json" - - try: - encode = encoders.find(media_range=content_type) - - except KeyError: - # Failed to find a matching encoder. - start_response("406 Not Acceptable", []) - return [b""] - - # Encode the data. - text = encode(data) - - # Return a successful response. - # TODO: We need a way to know the content-type here.. or the encoder - # needs to handle pushing the content-type. - start_response("202 Ok", [("Content-Type", "application/json")]) - return [text.encode("utf-8")] diff --git a/armet/resources.py b/armet/resources.py deleted file mode 100644 index da01063..0000000 --- a/armet/resources.py +++ /dev/null @@ -1,25 +0,0 @@ - -# NOTE: Should this be that `Registry` thing we were talking about? -_registry = {} - - -def resource(handler=None, **kwargs): - - def decorator(handler): - # Register the handler according to the name. - name = kwargs.get("name") or handler.__name__.lower() - if isinstance(handler, type): - _registry[name] = handler - - else: - _registry[name] = type(name, (), { - "route": lambda self, request: handler(request) - }) - - # Return the original function, unmodified. - return handler - - if handler is None: - return decorator - - return decorator(handler) From ebbe105440c9db833ce463eabb11f0755163b590 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 02:02:42 -0700 Subject: [PATCH 025/118] Add some text. --- demo.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 demo.md diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..f9ce3c1 --- /dev/null +++ b/demo.md @@ -0,0 +1,24 @@ +# Models + + - Poll + - question + - publish_date + - close_date + - choice(s) [relationship] + + - Choice + - poll [relationship] + - text + + - Vote + - choice [relationship] + - date + +# Stories +> Using "user" in the general sense (no auth yet) + + - User creates a poll + - User adds a choice to a poll + - User removes a choice from a poll + - User publishes a poll + - User votes for a choice on a poll From 2c2fe8f9d8d6935d0d9078cc1719e45fe736f610 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 02:05:41 -0700 Subject: [PATCH 026/118] Add some possible designs. --- demo.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/demo.md b/demo.md index f9ce3c1..3a707e3 100644 --- a/demo.md +++ b/demo.md @@ -17,8 +17,32 @@ # Stories > Using "user" in the general sense (no auth yet) - - User creates a poll - - User adds a choice to a poll - - User removes a choice from a poll - - User publishes a poll - - User votes for a choice on a poll +### User creates a poll + +``` +POST /poll +``` + +### User adds a choice to a poll + +``` +POST /poll/{id}/choice +``` + +### User removes a choice from a poll + +``` +DELETE /poll/{id}/choice/{id} +``` + +### User publishes a poll (?) + +``` +POST /poll/{id}/publish +``` + +### User votes for a choice on a poll + +``` +POST /poll/{id}/choice/{id}/vote +``` From a87e705ccbebeddc886657a30d3b1e9ab4b8ab74 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 02:08:16 -0700 Subject: [PATCH 027/118] More thoughts. --- demo.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/demo.md b/demo.md index 3a707e3..5e5165b 100644 --- a/demo.md +++ b/demo.md @@ -21,12 +21,20 @@ ``` POST /poll + +{ + "question": "...." +} ``` ### User adds a choice to a poll ``` POST /poll/{id}/choice + +{ + "text": "...." +} ``` ### User removes a choice from a poll @@ -39,10 +47,26 @@ DELETE /poll/{id}/choice/{id} ``` POST /poll/{id}/publish + +{ + // No data needed +} +``` + +``` +PATCH /poll/{id} + +{ + "published": true +} ``` ### User votes for a choice on a poll ``` POST /poll/{id}/choice/{id}/vote + +{ + // No data needed +} ``` From 697abe7ca5ae16d349363a49cdaabca3c40835f4 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Thu, 3 Jul 2014 09:19:04 -0700 Subject: [PATCH 028/118] Add preliminary form decoder. --- armet/__init__.py | 7 ++++++- armet/decoders.py | 7 +++++++ armet/encoders/__init__.py | 4 ++-- tests/test_decoders.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 5832cc4..2fa998f 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -20,7 +20,7 @@ mime_types=codecs.JSONCodec.mime_types) # FormData encoder -encoders.register(encoders.FormDataEncoder, +encoders.register(encoders.FormEncoder, names=codecs.FormDataCodec.names, mime_types=codecs.FormDataCodec.mime_types) @@ -34,3 +34,8 @@ decoders.register(decoders.JSONDecoder.decode, names=codecs.JSONCodec.names, mime_types=codecs.JSONCodec.mime_types) + +# Form Decoder +decoders.register(decoders.FormDecoder.decode, + names=codecs.FormDataCodec.names, + mime_types=codecs.FormDataCodec.mime_types) diff --git a/armet/decoders.py b/armet/decoders.py index d910ead..e993a8e 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -31,3 +31,10 @@ def decode(cls, text): return json.loads(text) except ValueError as ex: raise TypeError from ex + + +class FormDecoder: + + @classmethod + def decode(cls, text, boundary): + pass diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py index 2eeeb61..82188cd 100644 --- a/armet/encoders/__init__.py +++ b/armet/encoders/__init__.py @@ -1,5 +1,5 @@ from .general import register, remove, find, URLEncoder, JSONEncoder -from .form import form_data as FormDataEncoder +from .form import form_data as FormEncoder __all__ = [ @@ -8,5 +8,5 @@ find, URLEncoder, JSONEncoder, - FormDataEncoder, + FormEncoder, ] diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 2292821..4e74383 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -45,3 +45,39 @@ def test_encode_normal(self): def test_encode_failure(self): with pytest.raises(TypeError): self.decode('fail') + + +class TestFormDecoder: + def setup(self): + self.decode = decoders.find(name='form') + + def test_decode_normal(self): + boundary = 'abc123' + + data = ( + b'--abc123\r\n' + b'Content-Disposition: form-data; name=foo\r\n' + b'\r\n' + b'bar\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=bar\r\n' + b'\r\n' + b'baz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'buzz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'bang\r\n' + b'--abc123--' + ) + + expected = { + 'foo': 'bar', + 'bar': 'baz', + 'fizz': ['buz', 'bang'] + } + + assert self.decode(data, boundary=boundary) == expected From 2d6e700124a47eccfb072c6bede7aea363a289c5 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 3 Jul 2014 10:17:52 -0700 Subject: [PATCH 029/118] Update.: --- demo.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/demo.md b/demo.md index 5e5165b..a557e29 100644 --- a/demo.md +++ b/demo.md @@ -27,6 +27,27 @@ POST /poll } ``` +### User creates a poll with choices + +``` +POST /poll + +{ + "question": "...", + "choice": [ + { + "text": "...." + }, + { + "text": "...." + }, + { + "text": "...." + } + ] +} +``` + ### User adds a choice to a poll ``` From 654b3f25e38b149f72f56ee0af2c0b00a0fe6ac9 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Thu, 3 Jul 2014 12:24:15 -0700 Subject: [PATCH 030/118] Add form decoder. --- armet/decoders.py | 20 +++++++++++++++++++- tests/test_decoders.py | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/armet/decoders.py b/armet/decoders.py index e993a8e..f17a5c5 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,6 +1,9 @@ from .codecs import CodecRegistry import urllib.parse import json +import io +import cgi +import operator # Create our encoder registry and pull methods off it for easy access. @@ -37,4 +40,19 @@ class FormDecoder: @classmethod def decode(cls, text, boundary): - pass + fp = io.BytesIO(text) + result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) + + # We need to operate on the values to decode them and to unpack shallow + # ones. + keys, values = zip(*result.items()) + + # Decode the values + decode = operator.methodcaller('decode', 'utf8') + values = (list(map(decode, entry)) for entry in values) + + # Unpack shallow values (where there's only one) + values = (x if len(x) > 1 else x[0] for x in values) + + # Return the dictionary! + return dict(zip(keys, values)) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 2cee728..d1767a3 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -81,7 +81,7 @@ def test_decode_normal(self): expected = { 'foo': 'bar', 'bar': 'baz', - 'fizz': ['buz', 'bang'] + 'fiz': ['buzz', 'bang'] } assert self.decode(data, boundary=boundary) == expected From 224367ee61b856feedd84e66c7ee1cffc79fd7c7 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Thu, 3 Jul 2014 12:24:59 -0700 Subject: [PATCH 031/118] Tweak the api slightly. to catch more errors. --- armet/api.py | 26 +++++++++++++------------- armet/encoders/__init__.py | 12 ++++++------ armet/encoders/general.py | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/armet/api.py b/armet/api.py index df6db28..a0531e0 100644 --- a/armet/api.py +++ b/armet/api.py @@ -90,16 +90,16 @@ def __call__(self, environ, start_response): # mime_type would be faster. decode = decoders.find(media_range=content_type) - except KeyError: + # Decode the incoming request data. + # TODO: We should likely be sending the proper charset + # to ".decode(..)" + request_data = decode(request_raw_data.decode("utf-8")) + + except (KeyError, TypeError): # Failed to find a matching encoder. - start_response("415 Unsupported Media Type", []) + start_response("415 UNSUPPORTED MEDIA TYPE", []) return [b""] - # Decode the incoming request data. - # TODO: We should likely be sending the proper charset - # to ".decode(..)" - request_data = decode(request_raw_data.decode("utf-8")) - # Dispatch the request. response_data = route(request_data) @@ -111,14 +111,14 @@ def __call__(self, environ, start_response): try: encode = encoders.find(media_range=media_range) - except KeyError: + # Encode the data. + response_text = encode(response_data) + + except (KeyError, TypeError): # Failed to find a matching encoder. - start_response("406 Not Acceptable", []) + start_response("406 NOT ACCEPTABLE", []) return [b""] - # Encode the data. - response_text = encode(response_data) - # Return a successful response. - start_response("200 Ok", [("Content-Type", "application/json")]) + start_response("200 OK", [("Content-Type", "application/json")]) return [response_text.encode("utf-8")] diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py index 82188cd..2dbfa92 100644 --- a/armet/encoders/__init__.py +++ b/armet/encoders/__init__.py @@ -3,10 +3,10 @@ __all__ = [ - register, - remove, - find, - URLEncoder, - JSONEncoder, - FormEncoder, + 'register', + 'remove', + 'find', + 'URLEncoder', + 'JSONEncoder', + 'FormEncoder', ] diff --git a/armet/encoders/general.py b/armet/encoders/general.py index 39857e1..0eccc37 100644 --- a/armet/encoders/general.py +++ b/armet/encoders/general.py @@ -35,7 +35,7 @@ def encode(cls, data): # Ensure that the scalar data is wrapped in a list as # a valid JSON document must be an object or a list. # See: http://tools.ietf.org/html/rfc4627 - if not isinstance(data, str) and not isinstance(data, Iterable): + if isinstance(data, str) or not isinstance(data, Iterable): data = [data] # Separators are used here to assert that no uneccesary spaces are From 54e2e59f8e769a3f349f52fdddbc1e7ccac7fa29 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Thu, 3 Jul 2014 16:41:40 -0700 Subject: [PATCH 032/118] Added response object. --- armet/api.py | 43 +++++++++++++++++++++++++------------------ armet/codecs.py | 3 +-- armet/http.py | 6 +++++- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/armet/api.py b/armet/api.py index a0531e0..3517246 100644 --- a/armet/api.py +++ b/armet/api.py @@ -32,19 +32,24 @@ def register(self, handler, *, expose=True, name=None): self._registry[name] = handler def __call__(self, environ, start_response): - # TODO: Figure out how the response wrapper works and use it - # TODO: We need a way to know the content-type here.. or the encoder - # needs to handle pushing the content-type. - # Create the request wrapper around the environ (to make this at # least half-way sane). request = http.Request(environ) + response = http.Response() + + self.process(request, response) + + return response(environ, start_response) + + def process(self, request, response): + # TODO: We need a way to know the content-type here.. or the encoder + # needs to handle pushing the content-type. # Return an empty 404 if were accessed at "/" ( # we don't handle this yet). if request.path == "/": - start_response("404 Not Found", []) - return [b""] + response.status_code = 404 + return try: # Attempt to find the resource through the initial path. @@ -52,8 +57,8 @@ def __call__(self, environ, start_response): except KeyError: # Return a 404; we don't know what the resource is. - start_response("404 Not Found", []) - return [b""] + response.status_code = 404 + return # Instantiate the resource class. resource = resource_cls(request=request) @@ -68,8 +73,8 @@ def __call__(self, environ, start_response): except AttributeError: # Method is not allowed on the resource. - start_response("405 Method Not Allowed", []) - return [b""] + response.status_code = 405 + return # Read in the request data. # TODO: Think of a way to expose this (just "content" or "data") @@ -80,8 +85,8 @@ def __call__(self, environ, start_response): content_type = request.headers.get("Content-Type") if not content_type: # TODO: Handle content-type "detection" - start_response("415 Unsupported Media Type", []) - return [b""] + response.status_code = 415 + return try: # TODO: The content-type header could state more than just @@ -97,8 +102,8 @@ def __call__(self, environ, start_response): except (KeyError, TypeError): # Failed to find a matching encoder. - start_response("415 UNSUPPORTED MEDIA TYPE", []) - return [b""] + response.status_code = 415 + return # Dispatch the request. response_data = route(request_data) @@ -116,9 +121,11 @@ def __call__(self, environ, start_response): except (KeyError, TypeError): # Failed to find a matching encoder. - start_response("406 NOT ACCEPTABLE", []) - return [b""] + response.status_code = 406 + return # Return a successful response. - start_response("200 OK", [("Content-Type", "application/json")]) - return [response_text.encode("utf-8")] + response.status_code = 200 + response.headers['Content-Type'] = 'application/json' + response.data = response_text + return diff --git a/armet/codecs.py b/armet/codecs.py index a1e390d..9ea9f3d 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -78,12 +78,11 @@ class URLCodec: class JSONCodec: + # Offical; as per RFC 4627. preferred_mime_type = 'application/json' mime_types = { preferred_mime_type, - # Offical; as per RFC 4627. - 'application/json', # Widely used (thanks .) 'application/jsonrequest', diff --git a/armet/http.py b/armet/http.py index 0b41ddd..9ed6147 100644 --- a/armet/http.py +++ b/armet/http.py @@ -1,4 +1,4 @@ -from werkzeug.wrappers import BaseRequest +from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin class Request: @@ -17,3 +17,7 @@ def path(self): @property def headers(self): return self._handle.headers + + +class Response(BaseResponse, ResponseStreamMixin): + """There really doesn't need to be anything here.""" From 87226f1e42bdeff56e10f5bfe580c33ff0d8c63f Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Thu, 3 Jul 2014 18:35:09 -0700 Subject: [PATCH 033/118] Added some basic tests for the Api object --- armet/api.py | 11 +++++++---- tests/__init__.py | 0 tests/base.py | 41 +++++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/base.py create mode 100644 tests/test_api.py diff --git a/armet/api.py b/armet/api.py index 3517246..8b53e8d 100644 --- a/armet/api.py +++ b/armet/api.py @@ -37,11 +37,14 @@ def __call__(self, environ, start_response): request = http.Request(environ) response = http.Response() - self.process(request, response) + self.route(request, response) return response(environ, start_response) - def process(self, request, response): + def get_resource(self, path): + return self._registry[path] + + def route(self, request, response): # TODO: We need a way to know the content-type here.. or the encoder # needs to handle pushing the content-type. @@ -53,7 +56,7 @@ def process(self, request, response): try: # Attempt to find the resource through the initial path. - resource_cls = self._registry[request.path[1:]] + resource_cls = self.get_resource(request.path[1:]) except KeyError: # Return a 404; we don't know what the resource is. @@ -127,5 +130,5 @@ def process(self, request, response): # Return a successful response. response.status_code = 200 response.headers['Content-Type'] = 'application/json' - response.data = response_text + response.set_data(response_text) return diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..13eaf0b --- /dev/null +++ b/tests/base.py @@ -0,0 +1,41 @@ +from unittest import mock +from armet import api +import werkzeug.test +import functools + + +class RequestTest: + + def request(self, path, **kwargs): + # method and path are required arguments for sanity checking + # purposes. + + # By default, use json. + headers = kwargs.get('headers', {}) + headers.setdefault('Content-Type', 'application/json') + headers.setdefault('Accept', 'application/json') + + environ = werkzeug.test.create_environ( + path=path, + **kwargs) + + # import ipdb; ipdb.set_trace() + + return self.client.open(environ) + + # Helper functions! + def get(self, *args, **kwargs): + return self.request(*args, method='GET', **kwargs) + + def post(self, *args, **kwargs): + return self.request(*args, method='POST', **kwargs) + + def put(self, *args, **kwargs): + return self.request(*args, method='PUT', **kwargs) + + def delete(self, *args, **kwargs): + return self.request(*args, method='DELETE', **kwargs) + + def setup(self): + self.app = api.Api() + self.client = werkzeug.test.Client(self.app, werkzeug.Response) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c59d4cd --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,36 @@ +from .base import RequestTest +from armet import encoders, decoders +from unittest import mock + + +class TestAPI(RequestTest): + + def setup(self): + super().setup() + + # Register a dummy encoder and decoder. + self.codec = mock.MagicMock() + self.codec.return_value = b'test' + encoders.register(self.codec, names=['test'], mime_types=['test/test']) + decoders.register(self.codec, names=['test'], mime_types=['test/test']) + + def test_route_resource(self): + # Create and register some resources. to test api routing. + route_resource = mock.Mock(['route'], name='route_resource') + get_resource = mock.Mock(['get'], name='get_resource') + route_resource.route.return_value = {'foo': 'bar'} + get_resource.get.return_value = {'foo': 'bar'} + + self.app.register(lambda request: route_resource, name='route') + self.app.register(lambda request: get_resource, name='get') + + # Test routing to those resources. + headers = {'Accept': 'test/test', 'Content-Type': 'test/test'} + + response = self.get('/get', headers=headers) + assert response.status_code == 200 + assert get_resource.get.called + + response = self.get('/route', headers=headers) + assert response.status_code == 200 + assert route_resource.route.called From c4cbd8a74297ca58ac730432b25d5d795000134a Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 7 Jul 2014 13:55:43 -0700 Subject: [PATCH 034/118] Turn codec registration into a decorator and make encoders return generators for streaming response --- armet/__init__.py | 30 +++++++-------- armet/codecs.py | 50 ++++++++++++++++++++++++- armet/encoders/__init__.py | 10 ++--- armet/encoders/form.py | 34 +++++++++++++++-- armet/encoders/general.py | 76 ++++++++++++++++++++++++++------------ tests/test_codecs.py | 6 +++ tests/test_encoders.py | 21 +++++++++++ 7 files changed, 177 insertions(+), 50 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 2fa998f..5885e20 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -8,21 +8,21 @@ ] -# Register each encoder. -# URL Encoder -encoders.register(encoders.URLEncoder.encode, - names=codecs.URLCodec.names, - mime_types=codecs.URLCodec.mime_types) - -# JSON Encoder -encoders.register(encoders.JSONEncoder.encode, - names=codecs.JSONCodec.names, - mime_types=codecs.JSONCodec.mime_types) - -# FormData encoder -encoders.register(encoders.FormEncoder, - names=codecs.FormDataCodec.names, - mime_types=codecs.FormDataCodec.mime_types) +# # Register each encoder. +# # URL Encoder +# encoders.register(encoders.URLEncoder.encode, +# names=codecs.URLCodec.names, +# mime_types=codecs.URLCodec.mime_types) + +# # JSON Encoder +# encoders.register(encoders.JSONEncoder.encode, +# names=codecs.JSONCodec.names, +# mime_types=codecs.JSONCodec.mime_types) + +# # FormData encoder +# encoders.register(encoders.FormEncoder, +# names=codecs.FormDataCodec.names, +# mime_types=codecs.FormDataCodec.mime_types) # Register each decoder. # URL Decoder diff --git a/armet/codecs.py b/armet/codecs.py index 9ea9f3d..e513eba 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,14 +1,33 @@ import mimeparse +class Codec: + """A wrapper for a codec entered into the CodecRegistry. + """ + + def __init__(self, fn): + self._fn = fn + + def __call__(self, *args, **kwargs): + return self._fn(*args, **kwargs) + + class CodecRegistry: """A registry used for registering and removing encoders and decoders. """ - def __init__(self): + def __init__(self, Wrapper=Codec): + + # Codec registry. Maps names to codecs self._codecs = {} + + # Mime-type registry. Maps mime-types to codecs self._mime_types = {} + # Codec wrapper constructor. Returns a wrapped version of the codec + # Invoking the wrapper should invoke the codec. + self._Wrapper = Wrapper + def find(self, *, media_range=None, name=None, mime_type=None): """ Attempt to find a compliant codec given either a mime_type or @@ -40,10 +59,37 @@ def _find_media_range(self, media_range): return self._mime_types[found] - def register(self, codec, names=(), mime_types=()): + def register(self, names=(), mime_types=(), **kwargs): + """Register the transcoder provided in the global list of transcoders. + This is invoked as a decorator for the encoding function being + registered. + + EX: + + @register(names=['example'], mime_types=['example/example']) + def example_codec(): + return dostuff() + """ + def wrapper(fn): + # Register the function! + self._register(fn, names, mime_types, **kwargs) + + # We can just return the encoder directly. + # We're only registering it. + return fn + return wrapper + + def _register(self, codec_fn, names=(), mime_types=(), **kwargs): """Register the transcoder provided in the global list of transcoders. """ + # Sanity check. + assert (len(names) or len(mime_types), + "Encoder/Decoder cannot be registered without at least one of " + "'names' or 'mime_types'") + + codec = self._Wrapper(codec_fn, **kwargs) + self._codecs.update({x: codec for x in names}) self._mime_types.update({x: codec for x in mime_types}) diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py index 2dbfa92..84b1ac8 100644 --- a/armet/encoders/__init__.py +++ b/armet/encoders/__init__.py @@ -1,12 +1,12 @@ -from .general import register, remove, find, URLEncoder, JSONEncoder -from .form import form_data as FormEncoder +from .general import register, remove, find, url_encoder, json_encoder +from .form import form_encoder __all__ = [ 'register', 'remove', 'find', - 'URLEncoder', - 'JSONEncoder', - 'FormEncoder', + 'url_encoder', + 'json_encoder', + 'form_encoder', ] diff --git a/armet/encoders/form.py b/armet/encoders/form.py index 4bce53d..e260749 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -1,6 +1,6 @@ import uuid -from io import BytesIO import collections +from io import BytesIO def generate_boundary(): @@ -30,7 +30,32 @@ def retfn(string): # --AaB03x-- -def form_data(data, encoding='utf-8'): +def segment_stream(cache, buf, segment_size=16*1024): + """Yields bytes in `segment_size` chunks from cache and then from buf. + Operates by writing buf to cache and cleaning it every once in a while. + Expects cache to be a BytesIO and buf to be a bytes. + """ + while True: + size = cache.tell() + add = buf[:segment_size-size] + cache.write(add) + + value = cache.getvalue() + + # Reset the cache. + cache.truncate(0) + cache.seek(0) + + if not len(value): + break + + yield value + + +assert False, "Need to register this form encoder" + + +def form_encoder(data, encoding): """Expects to recieve a data structure of the following form: {name: value, name: [value1, value2]} """ @@ -69,7 +94,8 @@ def form_data(data, encoding='utf-8'): buf.write(bounds) # Write the contents. - buf.write(encode(entry)) + # TODO: Make this stream the contents of entry + yield from segment_stream(buf, encode(entry)) # Write the trailing newline buf.write(b'\r\n') @@ -77,4 +103,4 @@ def form_data(data, encoding='utf-8'): # All done. buf.write(b'--' + boundary + b'--') - return buf.getvalue() + yield from segment_stream(buf, b'') diff --git a/armet/encoders/general.py b/armet/encoders/general.py index 0eccc37..5e8c9e1 100644 --- a/armet/encoders/general.py +++ b/armet/encoders/general.py @@ -1,43 +1,71 @@ -from armet.codecs import CodecRegistry +from armet import codecs from itertools import chain, repeat from urllib.parse import urlencode from collections import Iterable import json +class Encoder(codecs.Codec): + """Wraps encoders to provide additonal decorator functionality.""" + + def __init__(self, fn, preferred_mime_type=None): + super().__init__(fn) + self.preferred_mime_type = preferred_mime_type + # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +_registry = codecs.CodecRegistry(Encoder) find = _registry.find remove = _registry.remove register = _registry.register -class URLEncoder: +def _chunk(data, chunk_size=16*1024): + """Simple chunking function to easily make encoders into generators. + Invocations of this should be replaced when more streaming-friendly + encoders are implemented.""" + while True: + buf = data[:chunk_size] + data = data[chunk_size:] + if not len(buf): + break + yield buf + + +@register( + names=codecs.URLCodec.names, + mime_types=codecs.URLCodec.mime_types, + preferred_mime_type=codecs.URLCodec.preferred_mime_type) +def url_encoder(data, encoding): + try: + # Normalize the encode so that users pay invoke using either + # {"foo": "bar"} or {"foo": ["bar", "baz"]}. + data = list(chain.from_iterable( + ((k, v),) if isinstance(v, str) else zip(repeat(k), v) + for k, v in data.items())) - @classmethod - def encode(cls, data): - try: - # Normalize the encode so that users pay invoke using either - # {"foo": "bar"} or {"foo": ["bar", "baz"]}. - return urlencode(list(chain.from_iterable( - ((k, v),) if isinstance(v, str) else zip(repeat(k), v) - for k, v in data.items()))) + except AttributeError as ex: + raise TypeError from ex - except AttributeError as ex: - raise TypeError from ex + # URL encode it and return a streamable generator. + # TODO: Replace this with actual url encoded streaming. + return _chunk(urlencode(data).encode(encoding)) -class JSONEncoder: +@register( + names=codecs.JSONCodec.names, + mime_types=codecs.JSONCodec.mime_types, + preferred_mime_type=codecs.JSONCodec.preferred_mime_type) +def json_encoder(data, encoding): + # Ensure that the scalar data is wrapped in a list as + # a valid JSON document must be an object or a list. + # See: http://tools.ietf.org/html/rfc4627 + if isinstance(data, str) or not isinstance(data, Iterable): + data = [data] - @classmethod - def encode(cls, data): - # Ensure that the scalar data is wrapped in a list as - # a valid JSON document must be an object or a list. - # See: http://tools.ietf.org/html/rfc4627 - if isinstance(data, str) or not isinstance(data, Iterable): - data = [data] + # Separators are used here to assert that no uneccesary spaces are + # added to the json. + data = json.dumps(data, separators=(',', ':')) - # Separators are used here to assert that no uneccesary spaces are - # added to the json. - return json.dumps(data, separators=(',', ':')) + # TODO: Replace this with real json streaming + return _chunk(data.encode(encoding)) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index aa559a2..69d8269 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -27,6 +27,12 @@ def test_remove_by_object(self): self.registry.remove(ExampleEncoder) pytest.raises(KeyError, self.registry.find, name="test") + def test_register_nothing(self): + with pytest.raises(AssertionError): + @self.registry.register() + def test(): + pass + def test_remove_by_name(self): self.registry.remove(name="example") pytest.raises(KeyError, self.registry.find, name="example") diff --git a/tests/test_encoders.py b/tests/test_encoders.py index e0ae28a..712fb26 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -12,6 +12,27 @@ def test_encoders_api_methods(): assert encoders.remove +class TestEncoderRegisterDecorator: + + def test_encode(self, data, encoding): + pass + + def test_register(self): + + mime = 'application/test' + args = { + 'name': ['test'], + 'mime_types': [mime], + 'preferred_mime_type': mime, + } + + @encoders.register(**args) + def encoder_test(data, encoding='utf-8'): + return json.dumps(data).encode(encoding), { + 'Content-Type': 'application/json' + } + + @mark.bench('self.encode', iterations=10000) class TestURLEncoder: From 86c0e29fd21e4793b51eac83c6347f969d8661be Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 14:45:38 -0700 Subject: [PATCH 035/118] Notes. --- armet/api.py | 19 ++++++++++-- play | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 play diff --git a/armet/api.py b/armet/api.py index 8b53e8d..e967e42 100644 --- a/armet/api.py +++ b/armet/api.py @@ -32,13 +32,21 @@ def register(self, handler, *, expose=True, name=None): self._registry[name] = handler def __call__(self, environ, start_response): - # Create the request wrapper around the environ (to make this at - # least half-way sane). + """Entry-point from the WSGI environment. + + When a request comes in from a "client" this is the first place + it goes after it is received by the "server" (uWSGI, nginx, etc.). + """ + + # Create the request and response wrappers around the environ. request = http.Request(environ) response = http.Response() + # Route the request.. needs a better name for the function perhaps. self.route(request, response) + # Invoke the response wrapper to initiate the (possibly streaming) + # response. return response(environ, start_response) def get_resource(self, path): @@ -54,6 +62,13 @@ def route(self, request, response): response.status_code = 404 return + # TODO: Parse URI and break up into + # /poll/3/choice => ("poll", "3", "choice") + # /poll/3 => ("poll", "3") + # /poll => ("poll") + + # TODO: Iterate through each pair in the sequence + try: # Attempt to find the resource through the initial path. resource_cls = self.get_resource(request.path[1:]) diff --git a/play b/play new file mode 100644 index 0000000..3b4dec7 --- /dev/null +++ b/play @@ -0,0 +1,88 @@ + +1. "client" sends "request" to "server" + +GET /poll +Accept: application/json + + +POST /poll +Accept: application/json +Content-Type: application/json +{ + "question": "wat" +} + +------------------------------------------------------------------------------- + +[x] 2. determine the "resource" to use + +/poll/12/choice + +"poll" (slug=12) +"choice" + + +------------------------------------------------------------------------------- + +[ ] 3. require "authentication" + +The idea is that an "API" may require authentication as a whole or +a "resource" may require a specific "kind" of authentication or allow +an additional kind of authentication that would only work for that resource + + - basic + - digest + - session + - bearer "token" + +------------------------------------------------------------------------------- + +[ ] 4. require "accessibilty" + +Is this "bearer" allowed to know this "resource" exists + +------------------------------------------------------------------------------- + +[ ] 5. require "method" + +Is this "bearer" (or in general is anyone) allowed to use this +specific "method" (GET, POST, PUT, ..) on this resource. + +> raise 405 + +------------------------------------------------------------------------------- + +[x] 6. "decoder" takes the textual data from the request body and converts it + into a usable python dict or list + +> raise 415 + +------------------------------------------------------------------------------- + +[ ] 7. Create and run the resource "operation" that corresponds to the method + for only one resource + +"GET" + +/poll/5/choice + +>>> poll = resource_map["poll"](slug="5").read() +>>> choice = resource_map["choice"](context={"poll": poll}).read() + +------------------------------------------------------------------------------- + +[ ] 8. require (or filter by) "authorization" + +------------------------------------------------------------------------------- + +[ ] 9. traverse and continue the chain (go back to 7 unless we're at the + final resource) + +------------------------------------------------------------------------------- + +[x] 10. take the response data and "encode" it using an encoder that the user + asked for (or the default encoder "json") + +=============================================================================== + +[ ] Resource base class From 9dc756f0b8b928ee2138e2a6db13460a6eaf7dbe Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 7 Jul 2014 16:51:17 -0700 Subject: [PATCH 036/118] Represent more information about the encoder when it is returned. --- armet/__init__.py | 30 +++++++++++++++--------------- armet/api.py | 9 +++++---- armet/codecs.py | 25 +++++++++++++++++-------- armet/encoders/form.py | 9 ++++++--- armet/encoders/general.py | 19 +++++++++++++++++-- tests/base.py | 2 -- tests/test_codecs.py | 20 ++++++++++---------- 7 files changed, 70 insertions(+), 44 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 5885e20..2c96622 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -24,18 +24,18 @@ # names=codecs.FormDataCodec.names, # mime_types=codecs.FormDataCodec.mime_types) -# Register each decoder. -# URL Decoder -decoders.register(decoders.URLDecoder.decode, - names=codecs.URLCodec.names, - mime_types=codecs.URLCodec.mime_types) - -# JSON Decoder -decoders.register(decoders.JSONDecoder.decode, - names=codecs.JSONCodec.names, - mime_types=codecs.JSONCodec.mime_types) - -# Form Decoder -decoders.register(decoders.FormDecoder.decode, - names=codecs.FormDataCodec.names, - mime_types=codecs.FormDataCodec.mime_types) +# # Register each decoder. +# # URL Decoder +# decoders.register(decoders.URLDecoder.decode, +# names=codecs.URLCodec.names, +# mime_types=codecs.URLCodec.mime_types) + +# # JSON Decoder +# decoders.register(decoders.JSONDecoder.decode, +# names=codecs.JSONCodec.names, +# mime_types=codecs.JSONCodec.mime_types) + +# # Form Decoder +# decoders.register(decoders.FormDecoder.decode, +# names=codecs.FormDataCodec.names, +# mime_types=codecs.FormDataCodec.mime_types) diff --git a/armet/api.py b/armet/api.py index 8b53e8d..fa0de15 100644 --- a/armet/api.py +++ b/armet/api.py @@ -117,10 +117,13 @@ def route(self, request, response): media_range = "application/json" try: - encode = encoders.find(media_range=media_range) + encoder = encoders.find(media_range=media_range) # Encode the data. - response_text = encode(response_data) + # TODO: We should be detecting the proper charset and using that + # instead. + response.response = encoder(response_data, 'utf-8') + response.headers['Content-Type'] = encoder.preferred_mime_type except (KeyError, TypeError): # Failed to find a matching encoder. @@ -129,6 +132,4 @@ def route(self, request, response): # Return a successful response. response.status_code = 200 - response.headers['Content-Type'] = 'application/json' - response.set_data(response_text) return diff --git a/armet/codecs.py b/armet/codecs.py index e513eba..70ecda1 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -5,11 +5,20 @@ class Codec: """A wrapper for a codec entered into the CodecRegistry. """ - def __init__(self, fn): - self._fn = fn + def __init__(self, fn, names, mime_types): + self.transcode = fn + self.names = names + self.mime_types = mime_types def __call__(self, *args, **kwargs): - return self._fn(*args, **kwargs) + return self.transcode(*args, **kwargs) + + def __eq__(self, other): + # Assert that both the transcoding function and the other are of the + # same type. So that we can even perform this comparison + if type(self.transcode) == type(other): + return self.transcode is other + return NotImplemented class CodecRegistry: @@ -84,11 +93,11 @@ def _register(self, codec_fn, names=(), mime_types=(), **kwargs): """ # Sanity check. - assert (len(names) or len(mime_types), - "Encoder/Decoder cannot be registered without at least one of " - "'names' or 'mime_types'") + assert len(names) or len(mime_types), ( + "Encoder/Decoder cannot be registered without at least one of " + "'names' or 'mime_types'") - codec = self._Wrapper(codec_fn, **kwargs) + codec = self._Wrapper(codec_fn, names, mime_types **kwargs) self._codecs.update({x: codec for x in names}) self._mime_types.update({x: codec for x in mime_types}) @@ -103,7 +112,7 @@ def remove(self, codec=None, *, name=None, mime_type=None): if codec is not None: for registry in (self._codecs, self._mime_types): for name, test in list(registry.items()): - if codec is test: + if codec == test: del registry[name] if name is not None: diff --git a/armet/encoders/form.py b/armet/encoders/form.py index e260749..04d463c 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -1,6 +1,8 @@ import uuid import collections from io import BytesIO +from .general import register +from armet.codecs import FormDataCodec def generate_boundary(): @@ -52,9 +54,10 @@ def segment_stream(cache, buf, segment_size=16*1024): yield value -assert False, "Need to register this form encoder" - - +@register( + names=FormDataCodec.names, + mime_types=FormDataCodec.mime_types, + preferred_mime_type=FormDataCodec.preferred_mime_type) def form_encoder(data, encoding): """Expects to recieve a data structure of the following form: {name: value, name: [value1, value2]} diff --git a/armet/encoders/general.py b/armet/encoders/general.py index 5e8c9e1..c54ba99 100644 --- a/armet/encoders/general.py +++ b/armet/encoders/general.py @@ -8,10 +8,25 @@ class Encoder(codecs.Codec): """Wraps encoders to provide additonal decorator functionality.""" - def __init__(self, fn, preferred_mime_type=None): - super().__init__(fn) + def __init__(self, *args, preferred_mime_type=None, **kwargs): + super().__init__(*args, **kwargs) self.preferred_mime_type = preferred_mime_type + @property + def preferred_mime_type(self): + mime = getattr(self, '_preferred_mime_type', None) + if not mime: + # Make an attempt to get a random preferred mime type. + # Fallback to plain text if no mimetypes were defined for this. + mime = next(iter(self.mime_types), 'text/plain') + self._preferred_mime_type = mime + + return mime + + @preferred_mime_type.setter + def preferred_mime_type_setter(self, value): + self._preferred_mime_type = value + # Create our encoder registry and pull methods off it for easy access. _registry = codecs.CodecRegistry(Encoder) diff --git a/tests/base.py b/tests/base.py index 13eaf0b..85d0474 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,7 +1,5 @@ -from unittest import mock from armet import api import werkzeug.test -import functools class RequestTest: diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 69d8269..2c0d084 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -15,13 +15,13 @@ class TestCodecRegistry: def setup(self): self.registry = CodecRegistry() self.registry.register( - ExampleEncoder, names=['test', 'example'], - mime_types=['application/octet-stream', 'test/test']) + mime_types=['application/octet-stream', 'test/test'])( + ExampleEncoder) self.registry.register( - CounterExampleEncoder, - mime_types=['application/xbel+xml', 'example/xml']) + mime_types=['application/xbel+xml', 'example/xml'])( + CounterExampleEncoder) def test_remove_by_object(self): self.registry.remove(ExampleEncoder) @@ -43,25 +43,25 @@ def test_remove_by_mime_type(self): def test_lookup_by_mime_type(self): mime = 'test/test' - assert self.registry.find(mime_type=mime) is ExampleEncoder + assert self.registry.find(mime_type=mime) == ExampleEncoder mime = 'application/octet-stream' - assert self.registry.find(mime_type=mime) is ExampleEncoder + assert self.registry.find(mime_type=mime) == ExampleEncoder def test_lookup_by_media_range(self): mime = 'example/*;q=0.5,*/*; q=0.1' - assert self.registry.find(media_range=mime) is CounterExampleEncoder + assert self.registry.find(media_range=mime) == CounterExampleEncoder mime = 'test/*;q=0.5,*/*; q=0.1' - assert self.registry.find(media_range=mime) is ExampleEncoder + assert self.registry.find(media_range=mime) == ExampleEncoder def test_malformed_media_range(self): with pytest.raises(KeyError): self.registry.find(media_range='asdf') def test_lookup_by_name(self): - assert self.registry.find(name='test') is ExampleEncoder - assert self.registry.find(name='example') is ExampleEncoder + assert self.registry.find(name='test') == ExampleEncoder + assert self.registry.find(name='example') == ExampleEncoder def test_lookup_failure(self): pytest.raises(KeyError, self.registry.find, name='missing') From b6624b65bc12c88db47bcc31d19593dee2ebe5f7 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 7 Jul 2014 16:59:16 -0700 Subject: [PATCH 037/118] removed regex --- armet/api.py | 25 +++++++++++++++---------- armet/utils.py | 13 +++++++++++++ tests/test_utils.py | 11 +++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 armet/utils.py create mode 100644 tests/test_utils.py diff --git a/armet/api.py b/armet/api.py index e967e42..dc1b612 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,15 +1,19 @@ from . import decoders, encoders, http -import re +from armet import utils def _dasherize(text): - # TODO: This could probably be optimized significantly. - text = text.strip() - text = re.sub(r'([A-Z])', r'-\1', text) - text = re.sub(r'[-_\s]+', r'-', text) - text = re.sub(r'^-', r'', text) - text = text.lower() - return text.strip() + # x = text[:-8] + x = text + final = '' + for item in x: + if item.isupper(): + final += "-" + item.lower() + else: + final += item + if final[0] == "-": + final = final[1:] + return final class Api: @@ -23,10 +27,11 @@ def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case - name = _dasherize(handler.__name__) + name = utils._dasherize(handler.__name__) # Strip a trailing '-resource' from it - name = re.sub(r'-resource$', '', name) + if name.endswith("-resource"): + name = name[:-9] # Insert the handler into the registry. self._registry[name] = handler diff --git a/armet/utils.py b/armet/utils.py new file mode 100644 index 0000000..00fbffe --- /dev/null +++ b/armet/utils.py @@ -0,0 +1,13 @@ + + +def dasherize(text): + x = text + final = '' + for item in x: + if item.isupper(): + final += "-" + item.lower() + else: + final += item + if final[0] == "-": + final = final[1:] + return final diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..4f613e6 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,11 @@ +from pytest import mark +from armet import utils + + +@mark.bench("utils.dasherize", iterations=100000) +class TestDasherize: + + def test_simple(self): + text = utils.dasherize("ContactPhoto") + + assert text == "contact-photo" From d162df1819590b538545b004baf9740a50d844b0 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 7 Jul 2014 17:03:12 -0700 Subject: [PATCH 038/118] removed idiocy --- armet/api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/armet/api.py b/armet/api.py index dc1b612..0bbe57d 100644 --- a/armet/api.py +++ b/armet/api.py @@ -3,10 +3,8 @@ def _dasherize(text): - # x = text[:-8] - x = text final = '' - for item in x: + for item in text: if item.isupper(): final += "-" + item.lower() else: From 0d03a614b39c4fa0e5f2520c3c12da746d4cd234 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 17:12:57 -0700 Subject: [PATCH 039/118] Add a resource base class. Lets add helpers as we go along. --- armet/resources.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 armet/resources.py diff --git a/armet/resources.py b/armet/resources.py new file mode 100644 index 0000000..f5b2b59 --- /dev/null +++ b/armet/resources.py @@ -0,0 +1,16 @@ + +class Resource: + + def __init__(self, slug=None, context=None): + """ + :param slug: Identifier that represents which item of the resource + to return, if present. + :type context: str or None + + :param context: Context in which this resource is being called (eg. + a nested resource will receive the result of a `read` + from the parent resource in its context). + :type context: dict or None + """ + self.slug = slug + self.context = context or {} From 27ef7b8ae2f58dc2034f1fd508498b481b0e2636 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 7 Jul 2014 17:20:22 -0700 Subject: [PATCH 040/118] Update decoders to use the same registration interface as the encoders. --- armet/__init__.py | 35 +----------------- armet/codecs.py | 2 +- armet/decoders.py | 71 +++++++++++++++++------------------ armet/encoders/form.py | 1 + armet/encoders/general.py | 4 +- tests/test_api.py | 4 +- tests/test_encoders.py | 78 +++++++++++++++++++++++---------------- 7 files changed, 90 insertions(+), 105 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 2c96622..5e02132 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,41 +1,8 @@ from ._version import __version__ -from . import encoders, decoders, codecs +from . import encoders, decoders __all__ = [ __version__, encoders, decoders ] - - -# # Register each encoder. -# # URL Encoder -# encoders.register(encoders.URLEncoder.encode, -# names=codecs.URLCodec.names, -# mime_types=codecs.URLCodec.mime_types) - -# # JSON Encoder -# encoders.register(encoders.JSONEncoder.encode, -# names=codecs.JSONCodec.names, -# mime_types=codecs.JSONCodec.mime_types) - -# # FormData encoder -# encoders.register(encoders.FormEncoder, -# names=codecs.FormDataCodec.names, -# mime_types=codecs.FormDataCodec.mime_types) - -# # Register each decoder. -# # URL Decoder -# decoders.register(decoders.URLDecoder.decode, -# names=codecs.URLCodec.names, -# mime_types=codecs.URLCodec.mime_types) - -# # JSON Decoder -# decoders.register(decoders.JSONDecoder.decode, -# names=codecs.JSONCodec.names, -# mime_types=codecs.JSONCodec.mime_types) - -# # Form Decoder -# decoders.register(decoders.FormDecoder.decode, -# names=codecs.FormDataCodec.names, -# mime_types=codecs.FormDataCodec.mime_types) diff --git a/armet/codecs.py b/armet/codecs.py index 70ecda1..72cf84f 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -97,7 +97,7 @@ def _register(self, codec_fn, names=(), mime_types=(), **kwargs): "Encoder/Decoder cannot be registered without at least one of " "'names' or 'mime_types'") - codec = self._Wrapper(codec_fn, names, mime_types **kwargs) + codec = self._Wrapper(codec_fn, names, mime_types, **kwargs) self._codecs.update({x: codec for x in names}) self._mime_types.update({x: codec for x in mime_types}) diff --git a/armet/decoders.py b/armet/decoders.py index f17a5c5..d33b95a 100644 --- a/armet/decoders.py +++ b/armet/decoders.py @@ -1,4 +1,5 @@ -from .codecs import CodecRegistry +# from .codecs import CodecRegistry +from . import codecs import urllib.parse import json import io @@ -7,52 +8,52 @@ # Create our encoder registry and pull methods off it for easy access. -_registry = CodecRegistry() +_registry = codecs.CodecRegistry() find = _registry.find remove = _registry.remove register = _registry.register -class URLDecoder: +@register( + names=codecs.URLCodec.names, + mime_types=codecs.URLCodec.mime_types) +def url_decode(text): + try: + data = urllib.parse.parse_qs(text) + return {k: v[0] if len(v) == 1 else v for k, v in data.items()} - @classmethod - def decode(cls, text): - try: - data = urllib.parse.parse_qs(text) - return {k: v[0] if len(v) == 1 else v for k, v in data.items()} + except AttributeError as ex: + raise TypeError from ex - except AttributeError as ex: - raise TypeError from ex +@register( + names=codecs.JSONCodec.names, + mime_types=codecs.JSONCodec.mime_types) +def json_decode(text): + try: + return json.loads(text) + except ValueError as ex: + raise TypeError from ex -class JSONDecoder: - @classmethod - def decode(cls, text): - try: - return json.loads(text) - except ValueError as ex: - raise TypeError from ex +@register( + names=codecs.FormDataCodec.names, + mime_types=codecs.FormDataCodec.mime_types) +def form_decode(text, boundary): + fp = io.BytesIO(text) + result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) + # We need to operate on the values to decode them and to unpack shallow + # ones. + keys, values = zip(*result.items()) -class FormDecoder: + # Decode the values + decode = operator.methodcaller('decode', 'utf8') + values = (list(map(decode, entry)) for entry in values) - @classmethod - def decode(cls, text, boundary): - fp = io.BytesIO(text) - result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) + # Unpack shallow values (where there's only one) + values = (x if len(x) > 1 else x[0] for x in values) - # We need to operate on the values to decode them and to unpack shallow - # ones. - keys, values = zip(*result.items()) - - # Decode the values - decode = operator.methodcaller('decode', 'utf8') - values = (list(map(decode, entry)) for entry in values) - - # Unpack shallow values (where there's only one) - values = (x if len(x) > 1 else x[0] for x in values) - - # Return the dictionary! - return dict(zip(keys, values)) + # Return the dictionary! + return dict(zip(keys, values)) diff --git a/armet/encoders/form.py b/armet/encoders/form.py index 04d463c..69ae213 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -40,6 +40,7 @@ def segment_stream(cache, buf, segment_size=16*1024): while True: size = cache.tell() add = buf[:segment_size-size] + buf = buf[segment_size-size:] cache.write(add) value = cache.getvalue() diff --git a/armet/encoders/general.py b/armet/encoders/general.py index c54ba99..4334746 100644 --- a/armet/encoders/general.py +++ b/armet/encoders/general.py @@ -15,7 +15,7 @@ def __init__(self, *args, preferred_mime_type=None, **kwargs): @property def preferred_mime_type(self): mime = getattr(self, '_preferred_mime_type', None) - if not mime: + if mime is None: # Make an attempt to get a random preferred mime type. # Fallback to plain text if no mimetypes were defined for this. mime = next(iter(self.mime_types), 'text/plain') @@ -24,7 +24,7 @@ def preferred_mime_type(self): return mime @preferred_mime_type.setter - def preferred_mime_type_setter(self, value): + def preferred_mime_type(self, value): self._preferred_mime_type = value # Create our encoder registry and pull methods off it for easy access. diff --git a/tests/test_api.py b/tests/test_api.py index c59d4cd..7c7a712 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,8 +11,8 @@ def setup(self): # Register a dummy encoder and decoder. self.codec = mock.MagicMock() self.codec.return_value = b'test' - encoders.register(self.codec, names=['test'], mime_types=['test/test']) - decoders.register(self.codec, names=['test'], mime_types=['test/test']) + encoders.register(names=['test'], mime_types=['test/test'])(self.codec) + decoders.register(names=['test'], mime_types=['test/test'])(self.codec) def test_route_resource(self): # Create and register some resources. to test api routing. diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 712fb26..2d5fe90 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -4,6 +4,8 @@ import pytest from pytest import mark from unittest import mock +from functools import reduce +import operator def test_encoders_api_methods(): @@ -14,30 +16,44 @@ def test_encoders_api_methods(): class TestEncoderRegisterDecorator: - def test_encode(self, data, encoding): - pass - def test_register(self): mime = 'application/test' args = { - 'name': ['test'], + 'names': ['test'], 'mime_types': [mime], 'preferred_mime_type': mime, } @encoders.register(**args) - def encoder_test(data, encoding='utf-8'): - return json.dumps(data).encode(encoding), { - 'Content-Type': 'application/json' - } + def encoder_test(data, encoding): + yield json.dumps(data).encode(encoding) + + encoder = encoders.find(name='test') + assert encoder == encoder_test + assert encoder.preferred_mime_type == mime + + def test_preferred_mime_type_fallback(self): + @encoders.register(names=['test']) + def encoder_test(data, encoding): + yield json.dumps(data).encode(encoding) + + encoder = encoders.find(name='test') + assert encoder.preferred_mime_type == 'text/plain' + + +class BaseEncoderTest: + def encode(self, data): + """Simple helper that makes encoder checking nicer.""" + encoded = reduce(operator.add, self.encoder(data, 'utf-8')) + return encoded.decode('utf-8') @mark.bench('self.encode', iterations=10000) -class TestURLEncoder: +class TestURLEncoder(BaseEncoderTest): def setup(self): - self.encode = encoders.find(name='url') + self.encoder = encoders.find(name='url') def test_encode_normal(self): data = OrderedDict(( @@ -55,10 +71,10 @@ def test_unable_to_encode(self): @mark.bench('self.encode', iterations=10000) -class TestJSONEncoder: +class TestJSONEncoder(BaseEncoderTest): def setup(self): - self.encode = encoders.find(name='json') + self.encoder = encoders.find(name='json') def test_encode_scalar(self): data = False @@ -93,9 +109,9 @@ def test_encode_failure(self): self.encode({'foo': range(10)}) -class TestFormDataEncoder: +class TestFormDataEncoder(BaseEncoderTest): def setup(self): - self.encode = encoders.find(name='form') + self.encoder = encoders.find(name='form') @mock.patch('armet.encoders.form.generate_boundary') def test_encode_normal(self, mocked): @@ -108,23 +124,23 @@ def test_encode_normal(self, mocked): ('fiz', ['buzz', 'bang']))) expected = ( - b'--abc123\r\n' - b'Content-Disposition: form-data; name=foo\r\n' - b'\r\n' - b'bar\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=bar\r\n' - b'\r\n' - b'baz\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=fiz\r\n' - b'\r\n' - b'buzz\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=fiz\r\n' - b'\r\n' - b'bang\r\n' - b'--abc123--' + '--abc123\r\n' + 'Content-Disposition: form-data; name=foo\r\n' + '\r\n' + 'bar\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=bar\r\n' + '\r\n' + 'baz\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=fiz\r\n' + '\r\n' + 'buzz\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=fiz\r\n' + '\r\n' + 'bang\r\n' + '--abc123--' ) assert self.encode(data) == expected From b9dfea309be6302e28f02babdd9b31df1bcc953d Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 17:21:43 -0700 Subject: [PATCH 041/118] Add benchmarks for form decoder and encoder. --- tests/test_decoders.py | 2 ++ tests/test_encoders.py | 64 ++++++++++++++++++++++-------------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index d1767a3..c4fff7e 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -51,7 +51,9 @@ def test_decode_failure(self): self.decode('fail') +@mark.bench('self.decode', iterations=10000) class TestFormDecoder: + def setup(self): self.decode = decoders.find(name='form') diff --git a/tests/test_encoders.py b/tests/test_encoders.py index e0ae28a..d881956 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -72,41 +72,43 @@ def test_encode_failure(self): self.encode({'foo': range(10)}) +@mark.bench('self.encode', iterations=10000) class TestFormDataEncoder: + def setup(self): self.encode = encoders.find(name='form') - @mock.patch('armet.encoders.form.generate_boundary') - def test_encode_normal(self, mocked): - # Assert that the mocked function always returns the same value. - mocked.return_value = 'abc123' - - data = OrderedDict(( - ('foo', 'bar'), - ('bar', 'baz'), - ('fiz', ['buzz', 'bang']))) - - expected = ( - b'--abc123\r\n' - b'Content-Disposition: form-data; name=foo\r\n' - b'\r\n' - b'bar\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=bar\r\n' - b'\r\n' - b'baz\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=fiz\r\n' - b'\r\n' - b'buzz\r\n' - b'--abc123\r\n' - b'Content-Disposition: form-data; name=fiz\r\n' - b'\r\n' - b'bang\r\n' - b'--abc123--' - ) - - assert self.encode(data) == expected + def test_encode_normal(self): + with mock.patch('armet.encoders.form.generate_boundary') as mocked: + # Assert that the mocked function always returns the same value. + mocked.return_value = 'abc123' + + data = OrderedDict(( + ('foo', 'bar'), + ('bar', 'baz'), + ('fiz', ['buzz', 'bang']))) + + expected = ( + b'--abc123\r\n' + b'Content-Disposition: form-data; name=foo\r\n' + b'\r\n' + b'bar\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=bar\r\n' + b'\r\n' + b'baz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'buzz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'bang\r\n' + b'--abc123--' + ) + + assert self.encode(data) == expected def test_encode_failure(self): with pytest.raises(TypeError): From cfb327978e390d3a9fdb02c19bdbc74bd2cd39f3 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 17:25:03 -0700 Subject: [PATCH 042/118] Update. --- tests/test_encoders.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index c344b51..867ad71 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -112,6 +112,9 @@ def test_encode_failure(self): @mark.bench('self.encode', iterations=10000) class TestFormDataEncoder(BaseEncoderTest): + def setup(self): + self.encoder = encoders.find(name='form') + def test_encode_normal(self): with mock.patch('armet.encoders.form.generate_boundary') as mocked: # Assert that the mocked function always returns the same value. From 9e653590bedb83a6a34eb708632d08d641a16d00 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 17:33:06 -0700 Subject: [PATCH 043/118] Update --- tests/test_encoders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 867ad71..c1b91e3 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -14,10 +14,10 @@ def test_encoders_api_methods(): assert encoders.remove +@mark.bench("encoders.register") class TestEncoderRegisterDecorator: def test_register(self): - mime = 'application/test' args = { 'names': ['test'], @@ -43,13 +43,14 @@ def encoder_test(data, encoding): class BaseEncoderTest: + def encode(self, data): """Simple helper that makes encoder checking nicer.""" encoded = reduce(operator.add, self.encoder(data, 'utf-8')) return encoded.decode('utf-8') -@mark.bench('self.encode', iterations=10000) +@mark.bench('self.encoder', iterations=10000) class TestURLEncoder(BaseEncoderTest): def setup(self): @@ -70,7 +71,7 @@ def test_unable_to_encode(self): self.encode([{'foo': 'bar'}]) -@mark.bench('self.encode', iterations=10000) +@mark.bench('self.encoder', iterations=10000) class TestJSONEncoder(BaseEncoderTest): def setup(self): @@ -109,7 +110,7 @@ def test_encode_failure(self): self.encode({'foo': range(10)}) -@mark.bench('self.encode', iterations=10000) +@mark.bench('self.encoder', iterations=10000) class TestFormDataEncoder(BaseEncoderTest): def setup(self): From 938ba9f94f2b1083a9585993ed631f44c36fcaeb Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 7 Jul 2014 18:02:23 -0700 Subject: [PATCH 044/118] Fix issue where Codec equality check wasn't checking every case. --- armet/codecs.py | 4 +++- tests/test_codecs.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/armet/codecs.py b/armet/codecs.py index 72cf84f..0131deb 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -17,7 +17,9 @@ def __eq__(self, other): # Assert that both the transcoding function and the other are of the # same type. So that we can even perform this comparison if type(self.transcode) == type(other): - return self.transcode is other + return self.transcode == other + elif isinstance(other, Codec): + return self.transcode == other.transcode return NotImplemented diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 2c0d084..e4bd333 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -41,6 +41,10 @@ def test_remove_by_mime_type(self): self.registry.remove(mime_type="example/xml") pytest.raises(KeyError, self.registry.find, mime_type="example/xml") + def test_remove_by_value(self): + self.registry.remove(ExampleEncoder) + pytest.raises(KeyError, self.registry.find, name='example') + def test_lookup_by_mime_type(self): mime = 'test/test' assert self.registry.find(mime_type=mime) == ExampleEncoder From 1408467d97a18b45717f0ed0896a55a8b56c76ce Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 7 Jul 2014 18:04:01 -0700 Subject: [PATCH 045/118] Simplify codec transcoding function checking --- armet/codecs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armet/codecs.py b/armet/codecs.py index 0131deb..7c2145d 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -16,7 +16,7 @@ def __call__(self, *args, **kwargs): def __eq__(self, other): # Assert that both the transcoding function and the other are of the # same type. So that we can even perform this comparison - if type(self.transcode) == type(other): + if self.transcode == other: return self.transcode == other elif isinstance(other, Codec): return self.transcode == other.transcode From e8ae6f12fcb47813fe581cca5e745132630e44c8 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 7 Jul 2014 18:30:42 -0700 Subject: [PATCH 046/118] Bunch of changes after some review. --- armet/api.py | 14 +------------- armet/codecs.py | 15 ++++++++------- armet/encoders/form.py | 2 +- armet/encoders/general.py | 25 ++++++++----------------- armet/utils.py | 18 +++++++++--------- tests/test_codecs.py | 2 +- 6 files changed, 28 insertions(+), 48 deletions(-) diff --git a/armet/api.py b/armet/api.py index 7faa9e5..90b6f7f 100644 --- a/armet/api.py +++ b/armet/api.py @@ -2,18 +2,6 @@ from armet import utils -def _dasherize(text): - final = '' - for item in text: - if item.isupper(): - final += "-" + item.lower() - else: - final += item - if final[0] == "-": - final = final[1:] - return final - - class Api: def __init__(self): @@ -25,7 +13,7 @@ def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case - name = utils._dasherize(handler.__name__) + name = utils.dasherize(handler.__name__) # Strip a trailing '-resource' from it if name.endswith("-resource"): diff --git a/armet/codecs.py b/armet/codecs.py index 7c2145d..6b3532c 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -14,13 +14,14 @@ def __call__(self, *args, **kwargs): return self.transcode(*args, **kwargs) def __eq__(self, other): - # Assert that both the transcoding function and the other are of the - # same type. So that we can even perform this comparison - if self.transcode == other: - return self.transcode == other - elif isinstance(other, Codec): - return self.transcode == other.transcode - return NotImplemented + try: + # Compare our contained function with the passed function or + # the passed container's function. + return (self.transcode == other or + self.transcode == other.transcode) + + except AttributeError: + return NotImplemented class CodecRegistry: diff --git a/armet/encoders/form.py b/armet/encoders/form.py index 69ae213..3f44bfe 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -49,7 +49,7 @@ def segment_stream(cache, buf, segment_size=16*1024): cache.truncate(0) cache.seek(0) - if not len(value): + if not value: break yield value diff --git a/armet/encoders/general.py b/armet/encoders/general.py index 4334746..121a91a 100644 --- a/armet/encoders/general.py +++ b/armet/encoders/general.py @@ -10,23 +10,12 @@ class Encoder(codecs.Codec): def __init__(self, *args, preferred_mime_type=None, **kwargs): super().__init__(*args, **kwargs) + # Make an attempt to get a random preferred mime type. + # Fallback to plain text if no mimetypes were defined for this. + if preferred_mime_type is None: + preferred_mime_type = next(iter(self.mime_types), 'text/plain') self.preferred_mime_type = preferred_mime_type - @property - def preferred_mime_type(self): - mime = getattr(self, '_preferred_mime_type', None) - if mime is None: - # Make an attempt to get a random preferred mime type. - # Fallback to plain text if no mimetypes were defined for this. - mime = next(iter(self.mime_types), 'text/plain') - self._preferred_mime_type = mime - - return mime - - @preferred_mime_type.setter - def preferred_mime_type(self, value): - self._preferred_mime_type = value - # Create our encoder registry and pull methods off it for easy access. _registry = codecs.CodecRegistry(Encoder) @@ -37,12 +26,14 @@ def preferred_mime_type(self, value): def _chunk(data, chunk_size=16*1024): """Simple chunking function to easily make encoders into generators. + Invocations of this should be replaced when more streaming-friendly - encoders are implemented.""" + encoders are implemented. + """ while True: buf = data[:chunk_size] data = data[chunk_size:] - if not len(buf): + if not buf: break yield buf diff --git a/armet/utils.py b/armet/utils.py index 00fbffe..b09dbfc 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -1,13 +1,13 @@ - def dasherize(text): - x = text - final = '' - for item in x: + result = '' + for item in text: if item.isupper(): - final += "-" + item.lower() + result += "-" + item.lower() else: - final += item - if final[0] == "-": - final = final[1:] - return final + result += item + + if result[0] == "-": + result = result[1:] + + return result diff --git a/tests/test_codecs.py b/tests/test_codecs.py index e4bd333..56b09d6 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,5 +1,5 @@ -from armet.codecs import CodecRegistry import pytest +from armet.codecs import CodecRegistry class ExampleEncoder: From 9d16eb994d00cfb58ddb8bcde91cd58a6809817a Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Tue, 8 Jul 2014 17:45:56 -0700 Subject: [PATCH 047/118] Exception Implementation, and some URL splitting + resource --- armet/api.py | 55 ++++++++-------- armet/http/__init__.py | 0 armet/http/exceptions.py | 136 +++++++++++++++++++++++++++++++++++++++ armet/utils.py | 9 +++ tests/test_utils.py | 2 +- 5 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 armet/http/__init__.py create mode 100644 armet/http/exceptions.py diff --git a/armet/api.py b/armet/api.py index 90b6f7f..726e82c 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,4 +1,5 @@ from . import decoders, encoders, http +from armet.http import exceptions from armet import utils @@ -9,7 +10,7 @@ def __init__(self): # That would give us the `remove` functionality easily self._registry = {} - def register(self, handler, *, expose=True, name=None): + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case @@ -34,7 +35,10 @@ def __call__(self, environ, start_response): response = http.Response() # Route the request.. needs a better name for the function perhaps. - self.route(request, response) + try: + self.route(request, response) + except Exception as ex: + response.status_code = ex # Invoke the response wrapper to initiate the (possibly streaming) # response. @@ -50,40 +54,40 @@ def route(self, request, response): # Return an empty 404 if were accessed at "/" ( # we don't handle this yet). if request.path == "/": - response.status_code = 404 - return + raise exceptions.NotFound() - # TODO: Parse URI and break up into - # /poll/3/choice => ("poll", "3", "choice") - # /poll/3 => ("poll", "3") - # /poll => ("poll") + name_slug_pair = utils.split_url(request.path) # TODO: Iterate through each pair in the sequence + resource_classes = [] + try: # Attempt to find the resource through the initial path. - resource_cls = self.get_resource(request.path[1:]) + for pair in name_slug_pair: + # pair is a tuple containing the name and slug, + # ("poll", 3), (choice, '') + resource_classes.append(self.get_resource(pair[0])) except KeyError: # Return a 404; we don't know what the resource is. - response.status_code = 404 - return + raise exceptions.NotFound() - # Instantiate the resource class. - resource = resource_cls(request=request) + # Instantiate the resource classes. + for resource in resource_classes: + resource(request=request) - # Route the resource appropriately. - try: - route = getattr(resource, request.method.lower()) - - except AttributeError: + # Route the resource appropriately. try: - route = resource.route + route = getattr(resource, request.method.lower()) except AttributeError: - # Method is not allowed on the resource. - response.status_code = 405 - return + try: + route = resource.route + + except AttributeError: + # Method is not allowed on the resource. + raise exceptions.MethodNotAllowed() # Read in the request data. # TODO: Think of a way to expose this (just "content" or "data") @@ -94,8 +98,7 @@ def route(self, request, response): content_type = request.headers.get("Content-Type") if not content_type: # TODO: Handle content-type "detection" - response.status_code = 415 - return + raise exceptions.UnsupportedMediaType() try: # TODO: The content-type header could state more than just @@ -111,7 +114,7 @@ def route(self, request, response): except (KeyError, TypeError): # Failed to find a matching encoder. - response.status_code = 415 + raise exceptions.UnsupportedMediaType() return # Dispatch the request. @@ -133,7 +136,7 @@ def route(self, request, response): except (KeyError, TypeError): # Failed to find a matching encoder. - response.status_code = 406 + response.status_code = exceptions.NotAcceptable() return # Return a successful response. diff --git a/armet/http/__init__.py b/armet/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py new file mode 100644 index 0000000..62ac5a1 --- /dev/null +++ b/armet/http/exceptions.py @@ -0,0 +1,136 @@ +from http import client + + +class BaseHTTPException(BaseException): + status = None + + def __init__(self, content=None, headers=None): + #! Body of the exception message. + self.content = content + + #! Additional headers to place with the response. + self.headers = headers or {} + + +class BadRequest(BaseHTTPException): # 400 + status = client.BAD_REQUEST + + +class Unauthorized(BaseHTTPException): # 401 + status = client.UNAUTHORIZED + + +class PaymentRequired(BaseHTTPException): # 402 + status = client.PAYMENT_REQUIRED + + +class Forbidden(BaseHTTPException): # 403 + status = client.FORBIDDEN + + +class NotFound(BaseHTTPException): # 404 + status = client.NOT_FOUND + + +class MethodNotAllowed(BaseHTTPException): # 405 + status = client.METHOD_NOT_ALLOWED + + def __init__(self, allowed): + super(MethodNotAllowed, self).__init__( + headers={'Allow': ', '.join(allowed)}) + + +class NotAcceptable(BaseHTTPException): # 406 + status = client.NOT_ACCEPTABLE + + +class ProxyAuthenticationRequired(BaseHTTPException): # 407 + status = client.PROXY_AUTHENTICATION_REQUIRED + + +class RequestTimeout(BaseHTTPException): # 408 + status = client.REQUEST_TIMEOUT + + +class Conflict(BaseHTTPException): # 409 + status = client.CONFLICT + + +class Gone(BaseHTTPException): # 410 + status = client.GONE + + +class LengthRequired(BaseHTTPException): # 411 + status = client.LENGTH_REQUIRED + + +class PreconditionFailed(BaseHTTPException): # 412 + status = client.PRECONDITION_FAILED + + +class RequestEntityTooLarge(BaseHTTPException): # 413 + status = client.REQUEST_ENTITY_TOO_LARGE + + +class RequestUriTooLong(BaseHTTPException): # 414 + status = client.REQUEST_URI_TOO_LONG + + +class UnsupportedMediaType(BaseHTTPException): # 415 + status = client.UNSUPPORTED_MEDIA_TYPE + + +class RequestedRangeNotSatisfiable(BaseHTTPException): # 416 + status = client.REQUESTED_RANGE_NOT_SATISFIABLE + + +class ExpectationFailed(BaseHTTPException): # 417 + status = client.EXPECTATION_FAILED + + +class UnprocessableEntity(BaseHTTPException): # 422 + status = client.UNPROCESSABLE_ENTITY + + +class Locked(BaseHTTPException): # 423 + status = client.LOCKED + + +class FailedDependency(BaseHTTPException): # 424 + status = client.FAILED_DEPENDENCY + + +class UgradeRequired(BaseHTTPException): # 426 + status = client.UPGRADE_REQUIRED + + +class InternalServerError(BaseHTTPException): # 500 + status = client.INTERNAL_SERVER_ERROR + + +class NotImplemented(BaseHTTPException): # 501 + status = client.NOT_IMPLEMENTED + + +class BadGateway(BaseHTTPException): # 502 + status = client.BAD_GATEWAY + + +class ServiceUnavailable(BaseHTTPException): # 503 + status = client.SERVICE_UNAVAILABLE + + +class GatewayTimeout(BaseHTTPException): # 504 + status = client.GATEWAY_TIMEOUT + + +class HttpVersionNotSupported(BaseHTTPException): # 505 + status = client.HTTP_VERSION_NOT_SUPPORTED + + +class InsufficientStorage(BaseHTTPException): # 507 + status = client.INSUFFICIENT_STORAGE + + +class NotExtended(BaseHTTPException): # 510 + status = client.NOT_EXTENDED diff --git a/armet/utils.py b/armet/utils.py index b09dbfc..8acab45 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -11,3 +11,12 @@ def dasherize(text): result = result[1:] return result + + +def split_url(path): + # We want to take the path, and split, then iterate into groups of two: + values = path.split("/") + if not len(values) % 2 is 0: + # Pad the list so we can iterate over it in pairs + values + [''] + return [(name, slug) for name, slug in zip(values, values[1:])[::2]] diff --git a/tests/test_utils.py b/tests/test_utils.py index 4f613e6..ab5cec0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ from armet import utils -@mark.bench("utils.dasherize", iterations=100000) +@mark.bench("utils.dasherize", iterations=1000000) class TestDasherize: def test_simple(self): From cd52a76efc241481ee4c49fbeeab6a53c4a0017f Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 8 Jul 2014 17:49:31 -0700 Subject: [PATCH 048/118] Merged. --- armet/api.py | 22 ++++++++++++++++++++-- play | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/armet/api.py b/armet/api.py index 726e82c..d42a5bb 100644 --- a/armet/api.py +++ b/armet/api.py @@ -10,7 +10,15 @@ def __init__(self): # That would give us the `remove` functionality easily self._registry = {} - def register(self, handler, *, expose=True, name=None): # noqa + def setup(self): + """Called on request setup in the context of this API. + """ + + def teardown(self): + """Called on request teardown in the context of this API. + """ + + def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case @@ -34,11 +42,21 @@ def __call__(self, environ, start_response): request = http.Request(environ) response = http.Response() + # Setup the request. + self.setup() + # Route the request.. needs a better name for the function perhaps. try: self.route(request, response) + except Exception as ex: - response.status_code = ex + response.status_code = ex.status + response.set_data(b"") + + # Teardown the request. + # FIXME: This should happen directly before closing the connection + # with the user, not here. + self.teardown() # Invoke the response wrapper to initiate the (possibly streaming) # response. diff --git a/play b/play index 3b4dec7..19b1fde 100644 --- a/play +++ b/play @@ -86,3 +86,47 @@ specific "method" (GET, POST, PUT, ..) on this resource. =============================================================================== [ ] Resource base class + + + + +POST /poll/13/choice/5/vote + +>>> poll_res = PollResource(slug=13) +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice}) +>>> vote_res.create(data) + +POST /poll/choice/5/vote + +>>> poll_res = PollResource(slug="choice") +>>> poll = poll_res.read() +# raise or return None +>>> poll_res = PollResource() +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice}) +>>> vote_res.create(data) + +POST /user/1/poll/choice/5/vote + +>>> user_res = UserResource(slug=1) +>>> user = user_res.read() +>>> poll_res = PollResource(slug="choice", context={"user": user}) +>>> poll = poll_res.read() +# raise or return None +>>> poll_res = PollResource(context={"user": user}) +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll, "user": user}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice, "user": user}) +>>> vote_res.create(data) + + +GET /poll/1/choice/2/vote +GET /choice/2/poll/vote + +/user/1/poll/5/user From d2cbb3e5b2154ff88a4321fac9a40247b103badb Mon Sep 17 00:00:00 2001 From: Pholey Date: Tue, 8 Jul 2014 18:33:30 -0700 Subject: [PATCH 049/118] rerouting --- armet/api.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/armet/api.py b/armet/api.py index 726e82c..a7cf53e 100644 --- a/armet/api.py +++ b/armet/api.py @@ -5,12 +5,22 @@ class Api: - def __init__(self): + def __init__(self, trailing_slash=True): # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} + self.trailing_slash = trailing_slash + + def reroute(self, request, response): + """Reroute the user to the correct URI""" + response.headers['Location'] = request.path + "/" + if not request.method == "GET": + response.status_code = 301 + return + response.status_code = 307 + + def register(self, handler, *, expose=True, name=None): # noqa - def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case @@ -34,6 +44,10 @@ def __call__(self, environ, start_response): request = http.Request(environ) response = http.Response() + if not self.trailing_slash: + self.reroute(request, response) + return response(environ, start_response) + # Route the request.. needs a better name for the function perhaps. try: self.route(request, response) From ee60a275f186ff79027bbade29f7e9917d3024e1 Mon Sep 17 00:00:00 2001 From: Pholey Date: Tue, 8 Jul 2014 18:37:11 -0700 Subject: [PATCH 050/118] fixed indention --- armet/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armet/api.py b/armet/api.py index 44e4517..e07f046 100644 --- a/armet/api.py +++ b/armet/api.py @@ -26,7 +26,7 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): # noqa + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case @@ -62,7 +62,7 @@ def __call__(self, environ, start_response): except Exception as ex: response.status_code = ex.status - response.set_data(b"") + response.set_data(b"") # Teardown the request. # FIXME: This should happen directly before closing the connection From a6d5a70be6fcaf73240fce9aef50284616154fd6 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 8 Jul 2014 20:25:02 -0700 Subject: [PATCH 051/118] Various changes and updates after review. --- armet/api.py | 98 ++++++++++++++++++++++++---------------- armet/http.py | 23 ---------- armet/http/__init__.py | 9 ++++ armet/http/exceptions.py | 62 ++++++++++++------------- armet/utils.py | 14 ++++-- 5 files changed, 107 insertions(+), 99 deletions(-) delete mode 100644 armet/http.py diff --git a/armet/api.py b/armet/api.py index e07f046..871496f 100644 --- a/armet/api.py +++ b/armet/api.py @@ -10,6 +10,12 @@ def __init__(self, trailing_slash=True): # That would give us the `remove` functionality easily self._registry = {} + # Trailing slash handling. + # The value indicates which URI is the canonical URI and the + # alternative URI is then made to redirect (with a 301) to the + # canonical URI. + self.trailing_slash = trailing_slash + def reroute(self, request, response): """Reroute the user to the correct URI""" response.headers['Location'] = request.path + "/" @@ -45,7 +51,6 @@ def __call__(self, environ, start_response): When a request comes in from a "client" this is the first place it goes after it is received by the "server" (uWSGI, nginx, etc.). """ - # Create the request and response wrappers around the environ. request = http.Request(environ) response = http.Response() @@ -60,7 +65,11 @@ def __call__(self, environ, start_response): try: self.route(request, response) - except Exception as ex: + except exceptions.Base as ex: + # An HTTP/1.1 understood exception was raised from somewhere + # within the request cycle. We Pull the status and headers + # from the exception object. + response.headers.extend(ex.headers.items()) response.status_code = ex.status response.set_data(b"") @@ -74,7 +83,11 @@ def __call__(self, environ, start_response): return response(environ, start_response) def get_resource(self, path): - return self._registry[path] + try: + return self._registry[path] + + except KeyError: + raise exceptions.NotFound def route(self, request, response): # TODO: We need a way to know the content-type here.. or the encoder @@ -85,42 +98,9 @@ def route(self, request, response): if request.path == "/": raise exceptions.NotFound() - name_slug_pair = utils.split_url(request.path) - - # TODO: Iterate through each pair in the sequence - - resource_classes = [] - - try: - # Attempt to find the resource through the initial path. - for pair in name_slug_pair: - # pair is a tuple containing the name and slug, - # ("poll", 3), (choice, '') - resource_classes.append(self.get_resource(pair[0])) - - except KeyError: - # Return a 404; we don't know what the resource is. - raise exceptions.NotFound() - - # Instantiate the resource classes. - for resource in resource_classes: - resource(request=request) - - # Route the resource appropriately. - try: - route = getattr(resource, request.method.lower()) - - except AttributeError: - try: - route = resource.route - - except AttributeError: - # Method is not allowed on the resource. - raise exceptions.MethodNotAllowed() - # Read in the request data. # TODO: Think of a way to expose this (just "content" or "data") - request_raw_data = request._handle.data + request_raw_data = request.data request_data = None if request_raw_data: # Find an available decoder. @@ -146,8 +126,44 @@ def route(self, request, response): raise exceptions.UnsupportedMediaType() return + # Attempt to find the resource through the initial path. + context = {} + segments = list(filter(None, request.path.split("/"))) + while len(segments) > 2: + # Pop the (name, slug) pair from the segments list. + name = segments.pop(0) + slug = segments.pop(0) + + # Attempt to lookup the resource from the passed name. + resource_cls = self.get_resource(name) + + # Instantiate the resource. + resource = resource_cls(slug=slug, context=context) + + # Invoke `.read` and store it in the context; we are not + # at the final segment in the url. + context[name] = resource.read() + + # Grab the final (name, slug?) pair from the list. + name = segments[0] + slug = segments[1] if len(segments) > 1 else None + + # Attempt to lookup the resource from the passed name. + resource_cls = self.get_resource(name) + + # Instantiate the resource. + resource = resource_cls(slug=slug, context=context) + + # We are at the final segment in the URL; we need to route this + # dependent on the HTTP/1.1 method. + try: + route = getattr(self, request.method.lower()) + + except AttributeError: + raise exceptions.MethodNotAllowed + # Dispatch the request. - response_data = route(request_data) + response_data = route(resource, request_data) # Find an available encoder. media_range = request.headers.get("Accept", "application/json") @@ -165,9 +181,11 @@ def route(self, request, response): except (KeyError, TypeError): # Failed to find a matching encoder. - response.status_code = exceptions.NotAcceptable() - return + raise exceptions.NotAcceptable # Return a successful response. response.status_code = 200 return + + def get(self, resource, data=None): + return resource.prepare(resource.read()) diff --git a/armet/http.py b/armet/http.py deleted file mode 100644 index 9ed6147..0000000 --- a/armet/http.py +++ /dev/null @@ -1,23 +0,0 @@ -from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin - - -class Request: - - def __init__(self, environ): - self._handle = BaseRequest(environ, populate_request=False) - - @property - def method(self): - return self._handle.method - - @property - def path(self): - return self._handle.path - - @property - def headers(self): - return self._handle.headers - - -class Response(BaseResponse, ResponseStreamMixin): - """There really doesn't need to be anything here.""" diff --git a/armet/http/__init__.py b/armet/http/__init__.py index e69de29..958d0e6 100644 --- a/armet/http/__init__.py +++ b/armet/http/__init__.py @@ -0,0 +1,9 @@ +from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin + + +class Request(BaseRequest): + pass + + +class Response(BaseResponse, ResponseStreamMixin): + pass diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index 62ac5a1..beb693e 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -1,7 +1,7 @@ from http import client -class BaseHTTPException(BaseException): +class Base(BaseException): status = None def __init__(self, content=None, headers=None): @@ -12,27 +12,27 @@ def __init__(self, content=None, headers=None): self.headers = headers or {} -class BadRequest(BaseHTTPException): # 400 +class BadRequest(Base): # 400 status = client.BAD_REQUEST -class Unauthorized(BaseHTTPException): # 401 +class Unauthorized(Base): # 401 status = client.UNAUTHORIZED -class PaymentRequired(BaseHTTPException): # 402 +class PaymentRequired(Base): # 402 status = client.PAYMENT_REQUIRED -class Forbidden(BaseHTTPException): # 403 +class Forbidden(Base): # 403 status = client.FORBIDDEN -class NotFound(BaseHTTPException): # 404 +class NotFound(Base): # 404 status = client.NOT_FOUND -class MethodNotAllowed(BaseHTTPException): # 405 +class MethodNotAllowed(Base): # 405 status = client.METHOD_NOT_ALLOWED def __init__(self, allowed): @@ -40,97 +40,97 @@ def __init__(self, allowed): headers={'Allow': ', '.join(allowed)}) -class NotAcceptable(BaseHTTPException): # 406 +class NotAcceptable(Base): # 406 status = client.NOT_ACCEPTABLE -class ProxyAuthenticationRequired(BaseHTTPException): # 407 +class ProxyAuthenticationRequired(Base): # 407 status = client.PROXY_AUTHENTICATION_REQUIRED -class RequestTimeout(BaseHTTPException): # 408 +class RequestTimeout(Base): # 408 status = client.REQUEST_TIMEOUT -class Conflict(BaseHTTPException): # 409 +class Conflict(Base): # 409 status = client.CONFLICT -class Gone(BaseHTTPException): # 410 +class Gone(Base): # 410 status = client.GONE -class LengthRequired(BaseHTTPException): # 411 +class LengthRequired(Base): # 411 status = client.LENGTH_REQUIRED -class PreconditionFailed(BaseHTTPException): # 412 +class PreconditionFailed(Base): # 412 status = client.PRECONDITION_FAILED -class RequestEntityTooLarge(BaseHTTPException): # 413 +class RequestEntityTooLarge(Base): # 413 status = client.REQUEST_ENTITY_TOO_LARGE -class RequestUriTooLong(BaseHTTPException): # 414 +class RequestUriTooLong(Base): # 414 status = client.REQUEST_URI_TOO_LONG -class UnsupportedMediaType(BaseHTTPException): # 415 +class UnsupportedMediaType(Base): # 415 status = client.UNSUPPORTED_MEDIA_TYPE -class RequestedRangeNotSatisfiable(BaseHTTPException): # 416 +class RequestedRangeNotSatisfiable(Base): # 416 status = client.REQUESTED_RANGE_NOT_SATISFIABLE -class ExpectationFailed(BaseHTTPException): # 417 +class ExpectationFailed(Base): # 417 status = client.EXPECTATION_FAILED -class UnprocessableEntity(BaseHTTPException): # 422 +class UnprocessableEntity(Base): # 422 status = client.UNPROCESSABLE_ENTITY -class Locked(BaseHTTPException): # 423 +class Locked(Base): # 423 status = client.LOCKED -class FailedDependency(BaseHTTPException): # 424 +class FailedDependency(Base): # 424 status = client.FAILED_DEPENDENCY -class UgradeRequired(BaseHTTPException): # 426 +class UgradeRequired(Base): # 426 status = client.UPGRADE_REQUIRED -class InternalServerError(BaseHTTPException): # 500 +class InternalServerError(Base): # 500 status = client.INTERNAL_SERVER_ERROR -class NotImplemented(BaseHTTPException): # 501 +class NotImplemented(Base): # 501 status = client.NOT_IMPLEMENTED -class BadGateway(BaseHTTPException): # 502 +class BadGateway(Base): # 502 status = client.BAD_GATEWAY -class ServiceUnavailable(BaseHTTPException): # 503 +class ServiceUnavailable(Base): # 503 status = client.SERVICE_UNAVAILABLE -class GatewayTimeout(BaseHTTPException): # 504 +class GatewayTimeout(Base): # 504 status = client.GATEWAY_TIMEOUT -class HttpVersionNotSupported(BaseHTTPException): # 505 +class HttpVersionNotSupported(Base): # 505 status = client.HTTP_VERSION_NOT_SUPPORTED -class InsufficientStorage(BaseHTTPException): # 507 +class InsufficientStorage(Base): # 507 status = client.INSUFFICIENT_STORAGE -class NotExtended(BaseHTTPException): # 510 +class NotExtended(Base): # 510 status = client.NOT_EXTENDED diff --git a/armet/utils.py b/armet/utils.py index 8acab45..4030d0a 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -15,8 +15,12 @@ def dasherize(text): def split_url(path): # We want to take the path, and split, then iterate into groups of two: - values = path.split("/") - if not len(values) % 2 is 0: - # Pad the list so we can iterate over it in pairs - values + [''] - return [(name, slug) for name, slug in zip(values, values[1:])[::2]] + values = filter(None, path.split("/")) + iterator = iter(values) + import ipdb; ipdb.set_trace() + while iterator: + name = next(iterator) + try: + yield (name, next(iterator)) + except StopIteration: + yield (name, None) From db1025c14b8d67a713c21311a1d776cb5bd65072 Mon Sep 17 00:00:00 2001 From: Pholey Date: Tue, 8 Jul 2014 21:19:33 -0700 Subject: [PATCH 052/118] mid reroute --- armet/api.py | 12 ------------ armet/http.py | 27 +++++++++++++++++++++++++++ armet/utils.py | 4 +++- 3 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 armet/http.py diff --git a/armet/api.py b/armet/api.py index 871496f..d761e18 100644 --- a/armet/api.py +++ b/armet/api.py @@ -16,14 +16,6 @@ def __init__(self, trailing_slash=True): # canonical URI. self.trailing_slash = trailing_slash - def reroute(self, request, response): - """Reroute the user to the correct URI""" - response.headers['Location'] = request.path + "/" - if not request.method == "GET": - response.status_code = 301 - return - response.status_code = 307 - def setup(self): """Called on request setup in the context of this API. """ @@ -38,10 +30,6 @@ def register(self, handler, *, expose=True, name=None): # noqa # Convert the name of the handler to dash-case name = utils.dasherize(handler.__name__) - # Strip a trailing '-resource' from it - if name.endswith("-resource"): - name = name[:-9] - # Insert the handler into the registry. self._registry[name] = handler diff --git a/armet/http.py b/armet/http.py new file mode 100644 index 0000000..14d7867 --- /dev/null +++ b/armet/http.py @@ -0,0 +1,27 @@ +from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin + + +# class Request: + +# def __init__(self, environ): +# self._handle = BaseRequest(environ, populate_request=False) + +# @property +# def method(self): +# return self._handle.method + +# @property +# def path(self): +# return self._handle.path + +# @property +# def headers(self): +# return self._handle.headers + + +class Request(BaseRequest): + pass + + +class Response(BaseResponse, ResponseStreamMixin): + """There really doesn't need to be anything here.""" diff --git a/armet/utils.py b/armet/utils.py index 4030d0a..13054b7 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -10,6 +10,9 @@ def dasherize(text): if result[0] == "-": result = result[1:] + if result.endswith("-resource"): + result = result[:-9] + return result @@ -17,7 +20,6 @@ def split_url(path): # We want to take the path, and split, then iterate into groups of two: values = filter(None, path.split("/")) iterator = iter(values) - import ipdb; ipdb.set_trace() while iterator: name = next(iterator) try: From 68d0ad0eddcd6e1f98016a6e72933cfb85591e7d Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 01:18:56 -0700 Subject: [PATCH 053/118] Reroute stuff --- armet/api.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index d761e18..8209a17 100644 --- a/armet/api.py +++ b/armet/api.py @@ -16,6 +16,20 @@ def __init__(self, trailing_slash=True): # canonical URI. self.trailing_slash = trailing_slash + def redirect(self, request, response): + path = request.path + '/' if self.trailing_slash else request.path[:-1] + response.headers['Location'] = "%s://%s%s%s%s" % ( + request.scheme, + request.host, + request.script_root, + request.path, + ('?' + request.query + if request.query else '')) + if request.method in ('GET', 'HEAD',): + response.status = exceptions.MOVED_PERMANENTLY + else: + response.status = http.client.TEMPORARY_REDIRECT + def setup(self): """Called on request setup in the context of this API. """ @@ -43,9 +57,10 @@ def __call__(self, environ, start_response): request = http.Request(environ) response = http.Response() - if not self.trailing_slash: + if self.trailing_slash ^ request.path.endswith('/'): self.reroute(request, response) return response(environ, start_response) + # Setup the request. self.setup() From 606c94c62be0d210318fd4e92abc2f1b2f33c713 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 01:19:48 -0700 Subject: [PATCH 054/118] Start working on prepares and cleans. --- armet/api.py | 3 +- armet/resources.py | 113 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 871496f..1b700aa 100644 --- a/armet/api.py +++ b/armet/api.py @@ -179,7 +179,8 @@ def route(self, request, response): response.response = encoder(response_data, 'utf-8') response.headers['Content-Type'] = encoder.preferred_mime_type - except (KeyError, TypeError): + except (KeyError, TypeError) as ex: + print(ex) # Failed to find a matching encoder. raise exceptions.NotAcceptable diff --git a/armet/resources.py b/armet/resources.py index f5b2b59..3eb677f 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -1,6 +1,18 @@ + +# Registry of preparation functions (by name and type) +_prepares = {} + +# Registry of cleaning functions (by name and type) +_cleans = {} + + class Resource: + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + def __init__(self, slug=None, context=None): """ :param slug: Identifier that represents which item of the resource @@ -14,3 +26,104 @@ def __init__(self, slug=None, context=None): """ self.slug = slug self.context = context or {} + + def prepare_item(self, item): + data = {} + for name in self.attributes: + try: + value = getattr(item, name) + + except AttributeError: + continue + + # Attempt to use a preparation function, by name. + name_prepare = _prepares.get(name) + if name_prepare: + value = name_prepare(item, name, value) + + data[name] = value + + return data + + def prepare(self, items): + data = [self.prepare_item(item) for item in items] + + if self.slug is None: + return data + + try: + return data[0] + + except IndexError: + raise exceptions.NotFound + + +def prepares(*clauses): + """ + Registers a preparation function to be invoked for each clause in + the decorator. + + A preparation clause is invoked on each attribute after it is retrieved + from the item returned from the `read` function. + + A clause may be a string or a type. If it is a string then the clause + describes an attribute of a resource by name. If it is a type then the + clause would be applied to all attributes of that type (or are an instance + of that type). + + A preparation function may be "scoped" to a specific resource + by decorating a method (instead of a module function). + + :: + @prepares(datetime.datetime) + def prepare_datetime(item, key, value): + return value.isoformat() + + @prepares("id") + def prepare_id(item, key, value): + return str(value) + + class User(Resource): + + @prepares("id") + def prepare_id(self, item, key, value): + return value.hex + """ + def decorator(fn): + # Register this preparation function for each passed clause. + for clause in clauses: + _prepares[clause] = fn + + # Return the original function. + return fn + + return decorator + + +def cleans(*clauses): + """ + Registers a cleaning function to be invoked for each clause in + the decorator. + + A cleaning clause is invoked on each attribute after it is received + from the decoder and before it is sent to the `update` or `create` + functions. + + Otherwise the semantics are equivalent to the `prepares` decorator. + + :: + @cleans(datetime.datetime) + def clean_datetime(key, value): + from dateutil import parse + return parse(value) + + @cleans("id") + def clean_id(key, value): + return UUID(value) + + class User(Resource): + + @cleans("id") + def clean_id(self, key, value): + return UUID(value) + """ From 77207cba35205833ca0cc29acfacd4624c6c5107 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 03:51:57 -0700 Subject: [PATCH 055/118] Improvements. --- armet/api.py | 11 +++++- armet/resources.py | 84 +++++++++++++++++++++++++++++++++++----------- tests/base.py | 6 ++-- tests/test_api.py | 14 ++++---- 4 files changed, 83 insertions(+), 32 deletions(-) diff --git a/armet/api.py b/armet/api.py index 1b700aa..37ab9bb 100644 --- a/armet/api.py +++ b/armet/api.py @@ -189,4 +189,13 @@ def route(self, request, response): return def get(self, resource, data=None): - return resource.prepare(resource.read()) + items = resource.read() + + if resource.slug is not None: + try: + return resource.prepare_item(items[0]) + + except TypeError: + return resource.prepare_item(items) + + return resource.prepare(items) diff --git a/armet/resources.py b/armet/resources.py index 3eb677f..6a399b8 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -1,10 +1,39 @@ +from collections import Iterable, Mapping, OrderedDict + + +class CycleRegistry(dict): + + def __init__(self, fallback=None): + super().__init__() + + # A fallback dictionary to use if the key is not in this dictionary. + self._fallback = fallback + + def __missing__(self, key): + # If the key is a "type" we should attempt to iterate back through + # its MRO to resolve it based on inheritance. + if isinstance(key, type): + for cls in key.__mro__: + if cls in self: + # Cache this for the next lookup. + self[key] = self[cls] + + # Return the result. + return self[cls] + + # If we have a fallback dictionary, use that. + if self._fallback: + return self._fallback[key] + + # Raise a KeyError as we don't have this. + raise KeyError # Registry of preparation functions (by name and type) -_prepares = {} +_prepares = CycleRegistry() # Registry of cleaning functions (by name and type) -_cleans = {} +_cleans = CycleRegistry() class Resource: @@ -28,34 +57,40 @@ def __init__(self, slug=None, context=None): self.context = context or {} def prepare_item(self, item): + # data = OrderedDict() data = {} for name in self.attributes: try: + # Attempt to get the attribute from the item. value = getattr(item, name) except AttributeError: + # Item does not have the attribute; just put `None` + # in the object. + data[name] = None continue - # Attempt to use a preparation function, by name. - name_prepare = _prepares.get(name) - if name_prepare: - value = name_prepare(item, name, value) - - data[name] = value + try: + # Attempt to get a preparation function, by type. + prepare = _prepares[type(value)] - return data + except KeyError: + try: + # Attempt to get a preparation function, by name. + prepare = _prepares[name] - def prepare(self, items): - data = [self.prepare_item(item) for item in items] + except KeyError: + # No preparation function; continue. + data[name] = value + continue - if self.slug is None: - return data + # Prepare and push the attribute in the object. + data[name] = prepare(item, name, value) - try: - return data[0] + return data - except IndexError: - raise exceptions.NotFound + def prepare(self, items): + return [self.prepare_item(item) for item in items] def prepares(*clauses): @@ -89,13 +124,13 @@ class User(Resource): def prepare_id(self, item, key, value): return value.hex """ - def decorator(fn): + def decorator(function): # Register this preparation function for each passed clause. for clause in clauses: - _prepares[clause] = fn + _prepares[clause] = function # Return the original function. - return fn + return function return decorator @@ -127,3 +162,12 @@ class User(Resource): def clean_id(self, key, value): return UUID(value) """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _cleans[clause] = function + + # Return the original function. + return function + + return decorator diff --git a/tests/base.py b/tests/base.py index 85d0474..2be1844 100644 --- a/tests/base.py +++ b/tests/base.py @@ -34,6 +34,6 @@ def put(self, *args, **kwargs): def delete(self, *args, **kwargs): return self.request(*args, method='DELETE', **kwargs) - def setup(self): - self.app = api.Api() - self.client = werkzeug.test.Client(self.app, werkzeug.Response) + def setup_class(cls): + cls.app = api.Api() + cls.client = werkzeug.test.Client(cls.app, werkzeug.Response) diff --git a/tests/test_api.py b/tests/test_api.py index 7c7a712..4cca29e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,8 +6,6 @@ class TestAPI(RequestTest): def setup(self): - super().setup() - # Register a dummy encoder and decoder. self.codec = mock.MagicMock() self.codec.return_value = b'test' @@ -16,21 +14,21 @@ def setup(self): def test_route_resource(self): # Create and register some resources. to test api routing. - route_resource = mock.Mock(['route'], name='route_resource') - get_resource = mock.Mock(['get'], name='get_resource') + route_resource = mock.Mock(name='route_resource') + get_resource = mock.Mock(name='get_resource') route_resource.route.return_value = {'foo': 'bar'} get_resource.get.return_value = {'foo': 'bar'} - self.app.register(lambda request: route_resource, name='route') - self.app.register(lambda request: get_resource, name='get') + self.app.register(lambda *a, **kw: route_resource, name='route') + self.app.register(lambda *a, **kw: get_resource, name='get') # Test routing to those resources. headers = {'Accept': 'test/test', 'Content-Type': 'test/test'} response = self.get('/get', headers=headers) assert response.status_code == 200 - assert get_resource.get.called + assert get_resource.read.called response = self.get('/route', headers=headers) assert response.status_code == 200 - assert route_resource.route.called + assert route_resource.read.called From 2029f0ae63522b5ad813256f888e7c92708b1db6 Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 13:12:18 -0700 Subject: [PATCH 056/118] uncommented --- armet/http.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/armet/http.py b/armet/http.py index 14d7867..e37ad73 100644 --- a/armet/http.py +++ b/armet/http.py @@ -1,26 +1,26 @@ from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin -# class Request: +class Request: -# def __init__(self, environ): -# self._handle = BaseRequest(environ, populate_request=False) + def __init__(self, environ): + self._handle = BaseRequest(environ, populate_request=False) -# @property -# def method(self): -# return self._handle.method + @property + def method(self): + return self._handle.method -# @property -# def path(self): -# return self._handle.path + @property + def path(self): + return self._handle.path -# @property -# def headers(self): -# return self._handle.headers + @property + def headers(self): + return self._handle.headers -class Request(BaseRequest): - pass +# class Request(BaseRequest): +# pass class Response(BaseResponse, ResponseStreamMixin): From 2f0064f443071fb4c44b12830ad9fa5dd4d3de8d Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 13:20:54 -0700 Subject: [PATCH 057/118] Fixed small things, we werkzeug now --- armet/api.py | 17 ++++++++++------- armet/http.py | 27 --------------------------- 2 files changed, 10 insertions(+), 34 deletions(-) delete mode 100644 armet/http.py diff --git a/armet/api.py b/armet/api.py index f67e3af..61b44b8 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,6 +1,7 @@ from . import decoders, encoders, http from armet.http import exceptions from armet import utils +import werkzeug class Api: @@ -18,17 +19,18 @@ def __init__(self, trailing_slash=True): def redirect(self, request, response): path = request.path + '/' if self.trailing_slash else request.path[:-1] - response.headers['Location'] = "%s://%s%s%s%s" % ( + location = "%s://%s%s%s%s" % ( request.scheme, request.host, request.script_root, request.path, - ('?' + request.query - if request.query else '')) + ('?' + request.query_string + if request.query_string else '')) if request.method in ('GET', 'HEAD',): - response.status = exceptions.MOVED_PERMANENTLY + status = http.client.MOVED_PERMANENTLY else: - response.status = http.client.TEMPORARY_REDIRECT + status = http.client.TEMPORARY_REDIRECT + return werkzeug.utils.redirect(location, status) def setup(self): """Called on request setup in the context of this API. @@ -58,8 +60,9 @@ def __call__(self, environ, start_response): response = http.Response() if self.trailing_slash ^ request.path.endswith('/'): - self.reroute(request, response) - return response(environ, start_response) + # self.reroute(request, response) + return redirect("...", code) + # return response(environ, start_response) # Setup the request. self.setup() diff --git a/armet/http.py b/armet/http.py deleted file mode 100644 index e37ad73..0000000 --- a/armet/http.py +++ /dev/null @@ -1,27 +0,0 @@ -from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin - - -class Request: - - def __init__(self, environ): - self._handle = BaseRequest(environ, populate_request=False) - - @property - def method(self): - return self._handle.method - - @property - def path(self): - return self._handle.path - - @property - def headers(self): - return self._handle.headers - - -# class Request(BaseRequest): -# pass - - -class Response(BaseResponse, ResponseStreamMixin): - """There really doesn't need to be anything here.""" From 9648eb2f141947035781d5dd6ae6cb138aa583c4 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 13:21:38 -0700 Subject: [PATCH 058/118] Add an example of armet usage with flask. --- examples/flask/requirements.txt | 1 + examples/flask/server.py | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 examples/flask/requirements.txt create mode 100644 examples/flask/server.py diff --git a/examples/flask/requirements.txt b/examples/flask/requirements.txt new file mode 100644 index 0000000..e3e9a71 --- /dev/null +++ b/examples/flask/requirements.txt @@ -0,0 +1 @@ +Flask diff --git a/examples/flask/server.py b/examples/flask/server.py new file mode 100644 index 0000000..697200d --- /dev/null +++ b/examples/flask/server.py @@ -0,0 +1,95 @@ +from flask import Flask +from armet.api import Api +from armet.resources import Resource, prepares +from armet.http.exceptions import NotFound +from werkzeug.wsgi import DispatcherMiddleware +from datetime import datetime, timedelta + +app = Flask(__name__) + + +class UserResource(Resource): + + # Data that this resource returns. + users = [ + { + 'id': 1, + 'username': 'bob', + 'email': 'bob@example.com', + 'password': 'secret', + 'created_date': datetime.now() - timedelta(days=2), + }, + { + 'id': 2, + 'username': 'joe', + 'email': 'joe@example.com', + 'password': 'hunter2', + 'created_date': datetime.now() + } + ] + + # All the attributes that will be returned when being read. + attributes = {'id', 'username', 'email', 'created_date'} + + def read(self): + # Implement slug based routing (/users/1 routes to a single one) + if self.slug is None: + # TODO: This is stupid. + data = [type('StupidObject', (), x) for x in self.users] + else: + try: + # Shoddy way of mapping ids to slugs (via array index.) + slug = int(self.slug) - 1 + except ValueError as ex: + raise NotFound() from ex + + if slug >= len(self.users) or slug < 0: + raise NotFound() + + # TODO: This is stupid. + data = type('StupidObject', (), self.users[slug]) + + return data + + +@prepares(datetime) +def prepare_datetime(item, key, value): + # Datetime objects are not inherently understood by many encoders + # (xml being an exception) + # so this function is in charge of normalizing the data into something + # that can be understood by most encdoders. Encoders will only + # use this preparation function when they cannot inherantly encode + # this format. + return value.isoformat() + + +@app.route('/hello') +def flask_route(): + # Flask routes still work. + return "Hello World!" + + +if __name__ == '__main__': + # Create an api that can contain this resource. + # Note that apis are valid wsgi applications, so we can mount/remount them + # as we would any other wsgi application. + api = Api() + + # Registering the resource within the application implicitly defines + # a route to the resource. The default route name is determined from the + # class name (cls.__name__). Take note of how capitalization maps: + # * UserResource maps to '/user' + # * MultiWordResource maps to '/user/multi-word' + api.register(UserResource) + + # Create a new wsgi application based on the flask routes and mount our + # armet api on /api + application = DispatcherMiddleware(app, { + '/api': api, + }) + + from werkzeug.serving import run_simple + run_simple( + '0.0.0.0', 5000, application, + use_debugger=False, + use_reloader=True) From 19e0b5b12f57eb6bc575d19ea12230d2f9612c73 Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 13:25:08 -0700 Subject: [PATCH 059/118] oops --- armet/api.py | 5 ++++- armet/utils.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/armet/api.py b/armet/api.py index 61b44b8..21ec3c9 100644 --- a/armet/api.py +++ b/armet/api.py @@ -46,6 +46,9 @@ def register(self, handler, *, expose=True, name=None): # noqa # Convert the name of the handler to dash-case name = utils.dasherize(handler.__name__) + if name.endswith("-resource"): + name = name[:-9] + # Insert the handler into the registry. self._registry[name] = handler @@ -61,7 +64,7 @@ def __call__(self, environ, start_response): if self.trailing_slash ^ request.path.endswith('/'): # self.reroute(request, response) - return redirect("...", code) + return redirect(request, response) # return response(environ, start_response) # Setup the request. diff --git a/armet/utils.py b/armet/utils.py index 13054b7..84c8f8d 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -10,9 +10,6 @@ def dasherize(text): if result[0] == "-": result = result[1:] - if result.endswith("-resource"): - result = result[:-9] - return result From 624708515a383ada050ad7a6bc8ec29cccbca6fc Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 13:58:46 -0700 Subject: [PATCH 060/118] Update. --- AUTHORS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 6d317c7..7bbfe0d 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -8,9 +8,9 @@ The python implementation of **Armet** is currently maintained by: Additional contributors (in no particular order): - James Miles — [@flyingbluejay][] -- Adam Voliva ([Concordus Applications][]) — [@avoliva][] +- Adam Voliva — [@avoliva][] +- Hardy Jones — [@joneshf][] - Taylor Stackpole ([Concordus Applications][]) — [@taystack][] -- Hardy Jones ([Concordus Applications][]) — [@joneshf][] [@flyingbluejay]: http://github.com/flyingbluejay [@avoliva]: http://github.com/avoliva From 886db0c643cb9c07aa879004367734a8aef8ccae Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 13:58:51 -0700 Subject: [PATCH 061/118] Remove unused function. --- armet/utils.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/armet/utils.py b/armet/utils.py index 4030d0a..b09dbfc 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -11,16 +11,3 @@ def dasherize(text): result = result[1:] return result - - -def split_url(path): - # We want to take the path, and split, then iterate into groups of two: - values = filter(None, path.split("/")) - iterator = iter(values) - import ipdb; ipdb.set_trace() - while iterator: - name = next(iterator) - try: - yield (name, next(iterator)) - except StopIteration: - yield (name, None) From dd4b426cb9c7ac81dbc66205b94dbbf22bdcadf3 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 14:00:13 -0700 Subject: [PATCH 062/118] Fix comments. --- armet/http/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index beb693e..b6d6491 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -5,10 +5,10 @@ class Base(BaseException): status = None def __init__(self, content=None, headers=None): - #! Body of the exception message. + # Body of the exception message. self.content = content - #! Additional headers to place with the response. + # Additional headers to place with the response. self.headers = headers or {} From 56c1f63ecffdcdde86ba4527609b6721da36ff01 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 14:43:11 -0700 Subject: [PATCH 063/118] Print tracebacks when errors happen. --- armet/api.py | 129 ++++++++++++++++++++++----------------- examples/flask/server.py | 16 ++--- tests/test_api.py | 26 +++++--- 3 files changed, 101 insertions(+), 70 deletions(-) diff --git a/armet/api.py b/armet/api.py index 37ab9bb..48dffcd 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,6 +1,7 @@ from . import decoders, encoders, http from armet.http import exceptions from armet import utils +import traceback class Api: @@ -62,16 +63,22 @@ def __call__(self, environ, start_response): self.setup() # Route the request.. needs a better name for the function perhaps. + # import ipdb; ipdb.set_trace() try: self.route(request, response) except exceptions.Base as ex: + # If the exception raised was an error-like exception (4xx or 5xx) + # then print the traceback as well. + if ex.status // 100 >= 4: + traceback.print_exc() + # An HTTP/1.1 understood exception was raised from somewhere # within the request cycle. We Pull the status and headers # from the exception object. response.headers.extend(ex.headers.items()) response.status_code = ex.status - response.set_data(b"") + response.set_data(b'') # Teardown the request. # FIXME: This should happen directly before closing the connection @@ -83,25 +90,45 @@ def __call__(self, environ, start_response): return response(environ, start_response) def get_resource(self, path): - try: - return self._registry[path] + """Uses the request path to instantiate and return the correct + resource using the slug and context. + """ - except KeyError: - raise exceptions.NotFound + # Attempt to find the resource through the initial path. + context = {} + # import ipdb; ipdb.set_trace() + segments = list(filter(None, path.split("/"))) + while len(segments) > 2: + # Pop the (name, slug) pair from the segments list. + name = segments.pop(0) + slug = segments.pop(0) - def route(self, request, response): - # TODO: We need a way to know the content-type here.. or the encoder - # needs to handle pushing the content-type. + # Attempt to lookup the resource from the passed name. + resource_cls = self._registry[path] - # Return an empty 404 if were accessed at "/" ( - # we don't handle this yet). - if request.path == "/": - raise exceptions.NotFound() + # Instantiate the resource. + resource = resource_cls(slug=slug, context=context) + # Invoke `.read` and store it in the context; we are not + # at the final segment in the url. + context[name] = resource.read() + + # Grab the final (name, slug?) pair from the list. + name = segments[0] + slug = segments[1] if len(segments) > 1 else None + + # Attempt to lookup the resource from the passed name. + if name not in self._registry: + raise exceptions.NotFound + resource_cls = self._registry[name] + + # Instantiate the resource. + return resource_cls(slug=slug, context=context) + + def decode(self, request): # Read in the request data. # TODO: Think of a way to expose this (just "content" or "data") request_raw_data = request.data - request_data = None if request_raw_data: # Find an available decoder. content_type = request.headers.get("Content-Type") @@ -119,40 +146,48 @@ def route(self, request, response): # Decode the incoming request data. # TODO: We should likely be sending the proper charset # to ".decode(..)" - request_data = decode(request_raw_data.decode("utf-8")) + return decode(request_raw_data.decode("utf-8")) except (KeyError, TypeError): - # Failed to find a matching encoder. + # Failed to find a matching decoder. raise exceptions.UnsupportedMediaType() - return - # Attempt to find the resource through the initial path. - context = {} - segments = list(filter(None, request.path.split("/"))) - while len(segments) > 2: - # Pop the (name, slug) pair from the segments list. - name = segments.pop(0) - slug = segments.pop(0) + # No request data + return None - # Attempt to lookup the resource from the passed name. - resource_cls = self.get_resource(name) + def encode(self, request, response, data): + # Find an available encoder. + media_range = request.headers.get("Accept", "application/json") + if media_range == "*/*": + media_range = "application/json" - # Instantiate the resource. - resource = resource_cls(slug=slug, context=context) + try: + encoder = encoders.find(media_range=media_range) - # Invoke `.read` and store it in the context; we are not - # at the final segment in the url. - context[name] = resource.read() + # Encode the data. + # TODO: We should be detecting the proper charset and using that + # instead. + response.response = encoder(data, 'utf-8') + response.headers['Content-Type'] = encoder.preferred_mime_type - # Grab the final (name, slug?) pair from the list. - name = segments[0] - slug = segments[1] if len(segments) > 1 else None + except (KeyError, TypeError) as ex: + # Failed to find a matching encoder. + raise exceptions.NotAcceptable from ex - # Attempt to lookup the resource from the passed name. - resource_cls = self.get_resource(name) + def route(self, request, response): + # TODO: We need a way to know the content-type here.. or the encoder + # needs to handle pushing the content-type. - # Instantiate the resource. - resource = resource_cls(slug=slug, context=context) + # Return an empty 404 if were accessed at "/" ( + # we don't handle this yet). + if request.path == "/": + raise exceptions.NotFound() + + # Get the request data + request_data = self.decode(request) + + # Instantiate the correct resource with + resource = self.get_resource(request.path) # We are at the final segment in the URL; we need to route this # dependent on the HTTP/1.1 method. @@ -165,24 +200,8 @@ def route(self, request, response): # Dispatch the request. response_data = route(resource, request_data) - # Find an available encoder. - media_range = request.headers.get("Accept", "application/json") - if media_range == "*/*": - media_range = "application/json" - - try: - encoder = encoders.find(media_range=media_range) - - # Encode the data. - # TODO: We should be detecting the proper charset and using that - # instead. - response.response = encoder(response_data, 'utf-8') - response.headers['Content-Type'] = encoder.preferred_mime_type - - except (KeyError, TypeError) as ex: - print(ex) - # Failed to find a matching encoder. - raise exceptions.NotAcceptable + # Write the response data into the response object + self.encode(request, response, response_data) # Return a successful response. response.status_code = 200 diff --git a/examples/flask/server.py b/examples/flask/server.py index 697200d..b9f26b6 100644 --- a/examples/flask/server.py +++ b/examples/flask/server.py @@ -8,6 +8,12 @@ app = Flask(__name__) +@app.route('/hello') +def flask_route(): + # Flask routes still work. + return "Hello World!" + + class UserResource(Resource): # Data that this resource returns. @@ -24,7 +30,7 @@ class UserResource(Resource): 'username': 'joe', 'email': 'joe@example.com', 'password': 'hunter2', - 'created_date': datetime.now() + 'created_date': datetime.now(), } ] @@ -63,12 +69,6 @@ def prepare_datetime(item, key, value): return value.isoformat() -@app.route('/hello') -def flask_route(): - # Flask routes still work. - return "Hello World!" - - if __name__ == '__main__': # Create an api that can contain this resource. # Note that apis are valid wsgi applications, so we can mount/remount them @@ -90,6 +90,6 @@ def flask_route(): from werkzeug.serving import run_simple run_simple( - '0.0.0.0', 5000, application, + 'localhost', 5000, application, use_debugger=False, use_reloader=True) diff --git a/tests/test_api.py b/tests/test_api.py index 4cca29e..66f78be 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,21 +14,33 @@ def setup(self): def test_route_resource(self): # Create and register some resources. to test api routing. + retval = {'foo': 'bar'} + route_resource = mock.Mock(name='route_resource') get_resource = mock.Mock(name='get_resource') - route_resource.route.return_value = {'foo': 'bar'} - get_resource.get.return_value = {'foo': 'bar'} + route_resource().route.return_value = retval + get_resource().read.return_value = retval - self.app.register(lambda *a, **kw: route_resource, name='route') - self.app.register(lambda *a, **kw: get_resource, name='get') + self.app.register(route_resource, name='route') + self.app.register(get_resource, name='read') # Test routing to those resources. headers = {'Accept': 'test/test', 'Content-Type': 'test/test'} - response = self.get('/get', headers=headers) + response = self.get('/read', headers=headers) assert response.status_code == 200 - assert get_resource.read.called + assert get_resource().read.called response = self.get('/route', headers=headers) assert response.status_code == 200 - assert route_resource.read.called + assert route_resource().read.called + + def test_internal_server_error_raises_500(self): + dead_resource = mock.Mock() + dead_resource().read.side_effect = ValueError('DeadResourceException!') + + self.app.register(dead_resource, name='test') + + response = self.get('/test') + assert response.status_code == 500 + assert dead_resource().read.called From 9dd7ef6dc5b9e4e869601981864232d6509cdf2a Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 15:19:10 -0700 Subject: [PATCH 064/118] This actually works now, with tests to boot! (i swear) Added rerouting --- armet/api.py | 23 ++++++++++------------- tests/base.py | 2 -- tests/test_api.py | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/armet/api.py b/armet/api.py index 21ec3c9..29be7a5 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,12 +1,13 @@ -from . import decoders, encoders, http -from armet.http import exceptions +from . import decoders, encoders +from armet.http import exceptions, Request, Response +import http from armet import utils import werkzeug class Api: - def __init__(self, trailing_slash=True): + def __init__(self, trailing_slash=False): # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} @@ -17,15 +18,13 @@ def __init__(self, trailing_slash=True): # canonical URI. self.trailing_slash = trailing_slash - def redirect(self, request, response): - path = request.path + '/' if self.trailing_slash else request.path[:-1] + def redirect(self, request): location = "%s://%s%s%s%s" % ( request.scheme, request.host, request.script_root, - request.path, - ('?' + request.query_string - if request.query_string else '')) + (request.path + '/' if self.trailing_slash else request.path[:-1]), + ('?' + request.query_string if request.query_string else '')) if request.method in ('GET', 'HEAD',): status = http.client.MOVED_PERMANENTLY else: @@ -59,13 +58,11 @@ def __call__(self, environ, start_response): it goes after it is received by the "server" (uWSGI, nginx, etc.). """ # Create the request and response wrappers around the environ. - request = http.Request(environ) - response = http.Response() + request = Request(environ) + response = Response() if self.trailing_slash ^ request.path.endswith('/'): - # self.reroute(request, response) - return redirect(request, response) - # return response(environ, start_response) + return self.redirect(request)(environ, start_response) # Setup the request. self.setup() diff --git a/tests/base.py b/tests/base.py index 2be1844..65c70dd 100644 --- a/tests/base.py +++ b/tests/base.py @@ -17,8 +17,6 @@ def request(self, path, **kwargs): path=path, **kwargs) - # import ipdb; ipdb.set_trace() - return self.client.open(environ) # Helper functions! diff --git a/tests/test_api.py b/tests/test_api.py index 4cca29e..0aebbac 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -32,3 +32,29 @@ def test_route_resource(self): response = self.get('/route', headers=headers) assert response.status_code == 200 assert route_resource.read.called + + # Unregistser resources + del self.app._registry["route"] + del self.app._registry["get"] + + def test_redirect_get(self): + response = self.get('/get/') + assert response.status_code == 301 + assert response.headers["Location"].endswith("/get") + + def test_redirect_get_inverse(self): + trailing_slash = self.app.trailing_slash + self.app.trailing_slash = True + + response = self.get('/get/') + assert response.status_code == 404 + + response = self.get('/get') + assert response.status_code == 301 + + self.app.trailing_slash = trailing_slash + + def test_redirect_post(self): + response = self.post('/post/') + assert response.status_code == 307 + assert response.headers["Location"].endswith("/post") From 25054e8147c50b4fc3d62fad3693de77c8e09436 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 15:35:38 -0700 Subject: [PATCH 065/118] Various minor cosmetic changes. --- armet/api.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/armet/api.py b/armet/api.py index 29be7a5..d701554 100644 --- a/armet/api.py +++ b/armet/api.py @@ -19,17 +19,24 @@ def __init__(self, trailing_slash=False): self.trailing_slash = trailing_slash def redirect(self, request): + # Format an absolute path to the URI (adjusted to be canonical). location = "%s://%s%s%s%s" % ( request.scheme, request.host, request.script_root, (request.path + '/' if self.trailing_slash else request.path[:-1]), ('?' + request.query_string if request.query_string else '')) - if request.method in ('GET', 'HEAD',): - status = http.client.MOVED_PERMANENTLY + + # Select the appropriate status_code based on the request method. + # MOVED_PERMANENTLY (301) only works for a GET but is preferred + # as it is cacheable. + if request.method == "GET": + status_code = http.client.MOVED_PERMANENTLY else: - status = http.client.TEMPORARY_REDIRECT - return werkzeug.utils.redirect(location, status) + status_code = http.client.TEMPORARY_REDIRECT + + # Build and return the redirection response. + return werkzeug.utils.redirect(location, status_code) def setup(self): """Called on request setup in the context of this API. @@ -57,18 +64,25 @@ def __call__(self, environ, start_response): When a request comes in from a "client" this is the first place it goes after it is received by the "server" (uWSGI, nginx, etc.). """ - # Create the request and response wrappers around the environ. + + # Create the request wrapper around the environment. request = Request(environ) - response = Response() + # Test and decide if we need to redirect the client to + # the canonical representation of the given request path. if self.trailing_slash ^ request.path.endswith('/'): - return self.redirect(request)(environ, start_response) + response = self.redirect(request) + return response(environ, start_response) # Setup the request. self.setup() - # Route the request.. needs a better name for the function perhaps. + # Build an empty response object. + response = Response() + try: + # Route the request.. needs a better name for the function + # perhaps. self.route(request, response) except exceptions.Base as ex: @@ -88,9 +102,9 @@ def __call__(self, environ, start_response): # response. return response(environ, start_response) - def get_resource(self, path): + def find(self, name): try: - return self._registry[path] + return self._registry[name] except KeyError: raise exceptions.NotFound @@ -141,7 +155,7 @@ def route(self, request, response): slug = segments.pop(0) # Attempt to lookup the resource from the passed name. - resource_cls = self.get_resource(name) + resource_cls = self.find(name) # Instantiate the resource. resource = resource_cls(slug=slug, context=context) @@ -155,7 +169,7 @@ def route(self, request, response): slug = segments[1] if len(segments) > 1 else None # Attempt to lookup the resource from the passed name. - resource_cls = self.get_resource(name) + resource_cls = self.find(name) # Instantiate the resource. resource = resource_cls(slug=slug, context=context) From 176784f3f0f9c9d050684de4fa8e4e3d4f3900cb Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 16:15:42 -0700 Subject: [PATCH 066/118] Refactor exceptions to utilize the werkzeug inheritance tree. Exceptions are now full-fledged response objects. --- armet/http/exceptions.py | 204 ++++++++++++++++++--------------------- 1 file changed, 96 insertions(+), 108 deletions(-) diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index beb693e..f9b5ee1 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -1,136 +1,124 @@ +from werkzeug import exceptions from http import client - - -class Base(BaseException): - status = None - - def __init__(self, content=None, headers=None): - #! Body of the exception message. - self.content = content - - #! Additional headers to place with the response. - self.headers = headers or {} - - -class BadRequest(Base): # 400 - status = client.BAD_REQUEST - - -class Unauthorized(Base): # 401 - status = client.UNAUTHORIZED +from collections import OrderedDict + +# Mirrors to werkzeug exceptions in the event we need to overload +# these ourselves. +Base = exceptions.HTTPException + + +class Base(exceptions.HTTPException): + + def get_body(self, environ=None): + # There is no body that needs to be returned for api exceptions. + return '' + + def get_headers(self, environ=None): + # Because we're no longer returning html, our content type is no + # longer text/html + # return [('Content-Type', 'text/plain')] + headers = OrderedDict(super().get_headers(environ)) + headers['Content-Type'] = 'text/plain' + return headers.items() + + +# The following exceptions are already implemented in werkzeug Wrap them +# in our exception class so they don't return html +BadRequest = Base.wrap(exceptions.BadRequest) +Unauthorized = Base.wrap(exceptions.Unauthorized) +Forbidden = Base.wrap(exceptions.Forbidden) +NotFound = Base.wrap(exceptions.NotFound) +MethodNotAllowed = Base.wrap(exceptions.MethodNotAllowed) +NotAcceptable = Base.wrap(exceptions.NotAcceptable) +RequestTimeout = Base.wrap(exceptions.RequestTimeout) +Conflict = Base.wrap(exceptions.Conflict) +Gone = Base.wrap(exceptions.Gone) +LengthRequired = Base.wrap(exceptions.LengthRequired) +PreconditionFailed = Base.wrap(exceptions.PreconditionFailed) +RequestEntityTooLarge = Base.wrap(exceptions.RequestEntityTooLarge) +UnsupportedMediaType = Base.wrap(exceptions.UnsupportedMediaType) +ExpectationFailed = Base.wrap(exceptions.ExpectationFailed) +UnprocessableEntity = Base.wrap(exceptions.UnprocessableEntity) +InternalServerError = Base.wrap(exceptions.InternalServerError) +NotImplemented = Base.wrap(exceptions.NotImplemented) +BadGateway = Base.wrap(exceptions.BadGateway) +ServiceUnavailable = Base.wrap(exceptions.ServiceUnavailable) +RequestedRangeNotSatisfiable = Base.wrap( + exceptions.RequestedRangeNotSatisfiable) + + +# The following exceptions are not already implemented in werkzeug and require +# that we create them ourselves. class PaymentRequired(Base): # 402 - status = client.PAYMENT_REQUIRED - - -class Forbidden(Base): # 403 - status = client.FORBIDDEN - - -class NotFound(Base): # 404 - status = client.NOT_FOUND - - -class MethodNotAllowed(Base): # 405 - status = client.METHOD_NOT_ALLOWED - - def __init__(self, allowed): - super(MethodNotAllowed, self).__init__( - headers={'Allow': ', '.join(allowed)}) - - -class NotAcceptable(Base): # 406 - status = client.NOT_ACCEPTABLE + code = client.PAYMENT_REQUIRED + description = ( + 'The server requires additional payment before ' + 'this resource can be accessed.' + ) class ProxyAuthenticationRequired(Base): # 407 - status = client.PROXY_AUTHENTICATION_REQUIRED - - -class RequestTimeout(Base): # 408 - status = client.REQUEST_TIMEOUT - - -class Conflict(Base): # 409 - status = client.CONFLICT - - -class Gone(Base): # 410 - status = client.GONE - - -class LengthRequired(Base): # 411 - status = client.LENGTH_REQUIRED - - -class PreconditionFailed(Base): # 412 - status = client.PRECONDITION_FAILED - - -class RequestEntityTooLarge(Base): # 413 - status = client.REQUEST_ENTITY_TOO_LARGE + code = client.PROXY_AUTHENTICATION_REQUIRED + description = ( + 'The proxy requires authentication before ' + 'this resource can be accessed.' + ) class RequestUriTooLong(Base): # 414 - status = client.REQUEST_URI_TOO_LONG - - -class UnsupportedMediaType(Base): # 415 - status = client.UNSUPPORTED_MEDIA_TYPE - - -class RequestedRangeNotSatisfiable(Base): # 416 - status = client.REQUESTED_RANGE_NOT_SATISFIABLE - - -class ExpectationFailed(Base): # 417 - status = client.EXPECTATION_FAILED - - -class UnprocessableEntity(Base): # 422 - status = client.UNPROCESSABLE_ENTITY + code = client.REQUEST_URI_TOO_LONG + description = ('The uri used to access this resource is too long.') class Locked(Base): # 423 - status = client.LOCKED + code = client.LOCKED + description = ( + 'This resource is currently locked. ' + 'Please try again later.' + ) class FailedDependency(Base): # 424 - status = client.FAILED_DEPENDENCY + code = client.FAILED_DEPENDENCY + description = ( + 'A prior request is reqired as a dependency for this request.' + ) class UgradeRequired(Base): # 426 - status = client.UPGRADE_REQUIRED - - -class InternalServerError(Base): # 500 - status = client.INTERNAL_SERVER_ERROR - - -class NotImplemented(Base): # 501 - status = client.NOT_IMPLEMENTED - - -class BadGateway(Base): # 502 - status = client.BAD_GATEWAY - - -class ServiceUnavailable(Base): # 503 - status = client.SERVICE_UNAVAILABLE + code = client.UPGRADE_REQUIRED + description = ( + 'Client should re-attempt request using a different protocol.' + ) class GatewayTimeout(Base): # 504 - status = client.GATEWAY_TIMEOUT + code = client.GATEWAY_TIMEOUT + description = ( + 'Gateway did not recieve a response from the upstream server.' + ) class HttpVersionNotSupported(Base): # 505 - status = client.HTTP_VERSION_NOT_SUPPORTED + code = client.HTTP_VERSION_NOT_SUPPORTED + description = ( + 'Server does not support the http protocol version used to connect.' + ) class InsufficientStorage(Base): # 507 - status = client.INSUFFICIENT_STORAGE - - -class NotExtended(Base): # 510 - status = client.NOT_EXTENDED + code = client.INSUFFICIENT_STORAGE + description = ( + 'Server has exceeded maximum storage capacity ' + 'required to complete this request' + ) + + +class NotExtended(Base): + code = client.NOT_EXTENDED + description = ( + 'Further extensions to the request are required' + 'for the server to fulfill it.' + ) From 52ed58df2077e6490f89db01dc3dff99fa07ef32 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 16:16:31 -0700 Subject: [PATCH 067/118] Fix issues with exception printing and hide it behind a debug flag. --- armet/api.py | 29 ++++++++++++++++++----------- tests/test_api.py | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/armet/api.py b/armet/api.py index 48dffcd..0edf0ec 100644 --- a/armet/api.py +++ b/armet/api.py @@ -6,11 +6,14 @@ class Api: - def __init__(self, trailing_slash=True): + def __init__(self, trailing_slash=True, debug=False): # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} + # Set if we're in debugging mode. + self.debug = debug + # Trailing slash handling. # The value indicates which URI is the canonical URI and the # alternative URI is then made to redirect (with a 301) to the @@ -33,7 +36,7 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): # noqa + def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case @@ -64,21 +67,25 @@ def __call__(self, environ, start_response): # Route the request.. needs a better name for the function perhaps. # import ipdb; ipdb.set_trace() + # self.route(request, response) try: self.route(request, response) except exceptions.Base as ex: # If the exception raised was an error-like exception (4xx or 5xx) # then print the traceback as well. - if ex.status // 100 >= 4: + if self.debug and ex.code // 100 >= 4: traceback.print_exc() # An HTTP/1.1 understood exception was raised from somewhere - # within the request cycle. We Pull the status and headers - # from the exception object. - response.headers.extend(ex.headers.items()) - response.status_code = ex.status - response.set_data(b'') + # These exceptions can be invoked the same way that response + # objects can. + response = ex + + except Exception: + if self.debug: + traceback.print_exc() + response = exceptions.InternalServerError() # Teardown the request. # FIXME: This should happen directly before closing the connection @@ -119,7 +126,7 @@ def get_resource(self, path): # Attempt to lookup the resource from the passed name. if name not in self._registry: - raise exceptions.NotFound + raise exceptions.NotFound() resource_cls = self._registry[name] # Instantiate the resource. @@ -172,7 +179,7 @@ def encode(self, request, response, data): except (KeyError, TypeError) as ex: # Failed to find a matching encoder. - raise exceptions.NotAcceptable from ex + raise exceptions.NotAcceptable() from ex def route(self, request, response): # TODO: We need a way to know the content-type here.. or the encoder @@ -195,7 +202,7 @@ def route(self, request, response): route = getattr(self, request.method.lower()) except AttributeError: - raise exceptions.MethodNotAllowed + raise exceptions.MethodNotAllowed() # Dispatch the request. response_data = route(resource, request_data) diff --git a/tests/test_api.py b/tests/test_api.py index 66f78be..e4e294a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,7 +14,7 @@ def setup(self): def test_route_resource(self): # Create and register some resources. to test api routing. - retval = {'foo': 'bar'} + retval = [{'foo': 'bar'}] route_resource = mock.Mock(name='route_resource') get_resource = mock.Mock(name='get_resource') From 70a7c2220a3143656d5301faa04eedea36414251 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 16:17:02 -0700 Subject: [PATCH 068/118] Update example server with debug flags. --- examples/flask/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/flask/server.py b/examples/flask/server.py index b9f26b6..525ef22 100644 --- a/examples/flask/server.py +++ b/examples/flask/server.py @@ -73,7 +73,7 @@ def prepare_datetime(item, key, value): # Create an api that can contain this resource. # Note that apis are valid wsgi applications, so we can mount/remount them # as we would any other wsgi application. - api = Api() + api = Api(debug=True) # Registering the resource within the application implicitly defines # a route to the resource. The default route name is determined from the @@ -88,6 +88,8 @@ def prepare_datetime(item, key, value): '/api': api, }) + app.debug = True + from werkzeug.serving import run_simple run_simple( 'localhost', 5000, application, From 5542ac1bca6341b3a52699a31227eb1f407e2c01 Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 16:17:17 -0700 Subject: [PATCH 069/118] NO_CONTENT if there isn't any --- armet/api.py | 8 +++++++- tests/test_api.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 29be7a5..0c34b84 100644 --- a/armet/api.py +++ b/armet/api.py @@ -166,10 +166,13 @@ def route(self, request, response): route = getattr(self, request.method.lower()) except AttributeError: - raise exceptions.MethodNotAllowed + raise exceptions.MethodNotAllowed(['get']) # Dispatch the request. response_data = route(resource, request_data) + if response_data is None: + response.status_code = 204 + return # Find an available encoder. media_range = request.headers.get("Accept", "application/json") @@ -197,6 +200,9 @@ def route(self, request, response): def get(self, resource, data=None): items = resource.read() + if items is None: + return + if resource.slug is not None: try: return resource.prepare_item(items[0]) diff --git a/tests/test_api.py b/tests/test_api.py index 0aebbac..037452f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ from .base import RequestTest +from armet.resources import Resource from armet import encoders, decoders from unittest import mock @@ -58,3 +59,17 @@ def test_redirect_post(self): response = self.post('/post/') assert response.status_code == 307 assert response.headers["Location"].endswith("/post") + + def test_no_content(self): + + class TestResource(Resource): + def read(self): + return None + + self.app.register(TestResource, name="test") + + response = self.get('/test') + + import ipdb; ipdb.set_trace() + assert response.status_code == 204 + From b97040a83d1c45c165859e338d90bd01014056aa Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 16:20:38 -0700 Subject: [PATCH 070/118] remove ipdb --- tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 037452f..c28eec9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -70,6 +70,5 @@ def read(self): response = self.get('/test') - import ipdb; ipdb.set_trace() assert response.status_code == 204 From dd5716284f1e69fdbff7c01e9e1d5379925e3f3c Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 16:22:31 -0700 Subject: [PATCH 071/118] pep-8 --- tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index c28eec9..7a1cb97 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -63,6 +63,7 @@ def test_redirect_post(self): def test_no_content(self): class TestResource(Resource): + def read(self): return None @@ -71,4 +72,3 @@ def read(self): response = self.get('/test') assert response.status_code == 204 - From e2fb3fb90b031a236e7ad93feb12be37885233f2 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 16:34:43 -0700 Subject: [PATCH 072/118] Remove state checking and fix issue where an api was only created once per class --- tests/base.py | 2 +- tests/test_api.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/base.py b/tests/base.py index 65c70dd..4484096 100644 --- a/tests/base.py +++ b/tests/base.py @@ -32,6 +32,6 @@ def put(self, *args, **kwargs): def delete(self, *args, **kwargs): return self.request(*args, method='DELETE', **kwargs) - def setup_class(cls): + def setup(cls): cls.app = api.Api() cls.client = werkzeug.test.Client(cls.app, werkzeug.Response) diff --git a/tests/test_api.py b/tests/test_api.py index cb24f17..4776ba9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,7 @@ class TestAPI(RequestTest): def setup(self): + super().setup() # Register a dummy encoder and decoder. self.codec = mock.MagicMock() self.codec.return_value = b'test' @@ -51,7 +52,6 @@ def test_redirect_get(self): assert response.headers["Location"].endswith("/get") def test_redirect_get_inverse(self): - trailing_slash = self.app.trailing_slash self.app.trailing_slash = True response = self.get('/get/') @@ -60,8 +60,6 @@ def test_redirect_get_inverse(self): response = self.get('/get') assert response.status_code == 301 - self.app.trailing_slash = trailing_slash - def test_redirect_post(self): response = self.post('/post/') assert response.status_code == 307 From 2edc1d8b024cdb01b9b987ab4af5a9f1fd6a3270 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Wed, 9 Jul 2014 16:35:24 -0700 Subject: [PATCH 073/118] Fix stuff lost during merging. --- armet/api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/armet/api.py b/armet/api.py index 285129a..7d690d1 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,8 +1,9 @@ from . import decoders, encoders +from armet import utils from armet.http import exceptions, Request, Response import http -from armet import utils import traceback +import werkzeug class Api: @@ -111,7 +112,7 @@ def __call__(self, environ, start_response): # response. return response(environ, start_response) - def get_resource(self, path): + def find(self, path): """Uses the request path to instantiate and return the correct resource using the slug and context. """ @@ -209,7 +210,7 @@ def route(self, request, response): request_data = self.decode(request) # Instantiate the correct resource with - resource = self.get_resource(request.path) + resource = self.find(request.path) # We are at the final segment in the URL; we need to route this # dependent on the HTTP/1.1 method. From 66e83e86d98c35c5550c5b5ee96c30c9634955c3 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 16:37:14 -0700 Subject: [PATCH 074/118] Remove blank line. --- armet/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 1b41b04..b81f271 100644 --- a/armet/api.py +++ b/armet/api.py @@ -51,7 +51,6 @@ def register(self, handler, *, expose=True, name=None): # noqa if name is None: # Convert the name of the handler to dash-case name = utils.dasherize(handler.__name__) - if name.endswith("-resource"): name = name[:-9] From 212c84cf0232ec2b5b048aaf500ec5eef094aa53 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 9 Jul 2014 17:00:59 -0700 Subject: [PATCH 075/118] Make this a fixture. --- tests/base.py | 9 ++++++--- tests/test_api.py | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/base.py b/tests/base.py index 4484096..7c10944 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,5 +1,6 @@ from armet import api import werkzeug.test +from pytest import fixture class RequestTest: @@ -32,6 +33,8 @@ def put(self, *args, **kwargs): def delete(self, *args, **kwargs): return self.request(*args, method='DELETE', **kwargs) - def setup(cls): - cls.app = api.Api() - cls.client = werkzeug.test.Client(cls.app, werkzeug.Response) + @fixture(autouse=True, scope="function") + def fixture_api(self, request): + inst = request.instance + inst.app = api.Api() + inst.client = werkzeug.test.Client(inst.app, werkzeug.Response) diff --git a/tests/test_api.py b/tests/test_api.py index 07b2fee..e834ccc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,7 +7,6 @@ class TestAPI(RequestTest): def setup(self): - super().setup() # Register a dummy encoder and decoder. self.codec = mock.MagicMock() self.codec.return_value = b'test' From cb60eb49849cac5f52a21a17c22f3a8193f898f3 Mon Sep 17 00:00:00 2001 From: Pholey Date: Wed, 9 Jul 2014 21:48:59 -0700 Subject: [PATCH 076/118] Generalizing registry, WIP --- armet/api.py | 7 +++-- armet/codecs.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/armet/api.py b/armet/api.py index 72881f9..4a12206 100644 --- a/armet/api.py +++ b/armet/api.py @@ -8,11 +8,14 @@ class Api: - def __init__(self, trailing_slash=False, debug=False): + def __init__(self, trailing_slash=False, debug=False, expose=True): # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} + # An attribute to disallow direct routing to a resource + # default is true. + self.expose = expose # Set if we're in debugging mode. self.debug = debug @@ -50,7 +53,7 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case diff --git a/armet/codecs.py b/armet/codecs.py index 6b3532c..50e2674 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,7 +1,9 @@ import mimeparse +from collections import Iterable class Codec: + """A wrapper for a codec entered into the CodecRegistry. """ @@ -24,7 +26,84 @@ def __eq__(self, other): return NotImplemented -class CodecRegistry: +class GenericRegistry: + + def __init__(self): + self._registry = {} + + def register(self, obj, **kwargs): + """ + A generic way to register attributes and their corrosponding + objects + """ + #{} + value_mapping = {} + if not kwargs: + raise TypeError("Cannot register with a key of None") + for key, value in kwargs.items(): + + if type(value) not in (list, str): + raise TypeError( + "Expected list or string, got %s" % type(value)) + elif type(value) is list: + for name in value: + + value_mapping[name] = obj + else: + value_mapping[key] = {str(value): obj} + + if self._registry.get(key): + value_mapping[key] = self._registry[key] + value_mapping[key][str(value)] = obj + + self._registry[key] = value_mapping[key] + + def find(self, **kwargs): + + for key, value in kwargs.items(): + if len(kwargs) > 1: + yield self._registry[key][value] + + return self._registry[key][value] + + def remove(self, *args, **kwargs): + + _ex = [] + + if not len(args) < 2: + for x in args: + self.remove(x) + + elif isinstance(args[0], Iterable): + for x in args[0]: + if isinstance(x, dict): + raise TypeError("Improper type %S" % type(x)) + self.remove(x) + elif len(args) is 1: + inverse = {} + for x, y in self._registry.items(): + if isinstance(y, dict): + for k, v in y.items(): + if args[0] is v: + try: + del self._registry[k] + except IndexError as ex: + _ex.append(ex) + continue + + for key, value in kwargs.items(): + try: + self.remove(self._registry[key][value]) + except IndexError as ex: + _ex.append(ex) + + # Dunno what to do with all the exceptions + # Return em! + return _ex if _ex else None + + +class CodecRegistry(GenericRegistry): + """A registry used for registering and removing encoders and decoders. """ @@ -40,7 +119,7 @@ def __init__(self, Wrapper=Codec): # Invoking the wrapper should invoke the codec. self._Wrapper = Wrapper - def find(self, *, media_range=None, name=None, mime_type=None): + def find(self, *, media_range=None, name=None, mime_type=None): # noqa """ Attempt to find a compliant codec given either a mime_type or codec name. Prioritize the mime-type, then name, then media_range. From d29d5317f437fbf50b64110d02db29c230511f1b Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 10 Jul 2014 14:10:20 -0700 Subject: [PATCH 077/118] Moved and some changes after review. --- armet/registry.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 armet/registry.py diff --git a/armet/registry.py b/armet/registry.py new file mode 100644 index 0000000..85365b0 --- /dev/null +++ b/armet/registry.py @@ -0,0 +1,50 @@ +from collections import defaultdict + + +class Registry: + + def __init__(self): + self._registry = defaultdict(dict) + + def register(self, obj, **kwargs): + if obj is None: + raise TypeError("'%s' object cannot be registered" % + type(obj).__name__) + + for key, value in kwargs.items(): + self._registry[key][value] = obj + + def find(self, **kwargs): + if len(kwargs) > 1: + raise TypeError( + "%s.find expected at most 1 keyword argument, got %d" % ( + type(self).__name__, len(kwargs))) + + try: + key, value = kwargs.popitem() + return self._registry[key][value] + + except KeyError: + # If we don't find what they were looking for; return nothing. + return None + + def remove(self, *args, **kwargs): + # For each passed object we need to iterate through each nested + # dictionary and remove each reference to it. + for obj in args: + for registry_name, registry in list(self._registry.items()): + for name, item in list(registry.items()): + if item is obj: + del registry[name] + + if not registry: + del self._registry[registry_name] + + # For each passed reference we retrieve the object at the reference + # and recurse into removing every reference for that object. + for key, value in kwargs.items(): + try: + self.remove(self._registry[key][value]) + + except IndexError: + pass From 0899a98c5fea4f02d6cc58e4fbb367756547af9b Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Thu, 10 Jul 2014 14:49:06 -0700 Subject: [PATCH 078/118] Move things around after the general registry came to life. --- armet/__init__.py | 6 +- armet/codecs.py | 239 ++++++++----------------------------- armet/decoders.py | 59 --------- armet/decoders/__init__.py | 21 ++++ armet/decoders/json.py | 14 +++ armet/decoders/url.py | 16 +++ armet/encoders/__init__.py | 25 ++-- armet/encoders/form.py | 110 ----------------- armet/encoders/general.py | 77 ------------ armet/encoders/json.py | 23 ++++ armet/encoders/url.py | 25 ++++ armet/registry.py | 42 +++++-- armet/utils.py | 14 +++ tests/test_api.py | 16 +-- tests/test_codecs.py | 2 + tests/test_decoders.py | 1 + tests/test_encoders.py | 2 + 17 files changed, 225 insertions(+), 467 deletions(-) delete mode 100644 armet/decoders.py create mode 100644 armet/decoders/__init__.py create mode 100644 armet/decoders/json.py create mode 100644 armet/decoders/url.py delete mode 100644 armet/encoders/form.py delete mode 100644 armet/encoders/general.py create mode 100644 armet/encoders/json.py create mode 100644 armet/encoders/url.py diff --git a/armet/__init__.py b/armet/__init__.py index 5e02132..513d9d4 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,8 +1,8 @@ from ._version import __version__ -from . import encoders, decoders +# from . import encoders, decoders __all__ = [ __version__, - encoders, - decoders + # encoders, + # decoders ] diff --git a/armet/codecs.py b/armet/codecs.py index 50e2674..bc99fd9 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,207 +1,70 @@ import mimeparse from collections import Iterable - - -class Codec: - - """A wrapper for a codec entered into the CodecRegistry. - """ - - def __init__(self, fn, names, mime_types): - self.transcode = fn - self.names = names - self.mime_types = mime_types - - def __call__(self, *args, **kwargs): - return self.transcode(*args, **kwargs) - - def __eq__(self, other): - try: - # Compare our contained function with the passed function or - # the passed container's function. - return (self.transcode == other or - self.transcode == other.transcode) - - except AttributeError: - return NotImplemented - - -class GenericRegistry: - - def __init__(self): - self._registry = {} - - def register(self, obj, **kwargs): - """ - A generic way to register attributes and their corrosponding - objects - """ - #{} - value_mapping = {} - if not kwargs: - raise TypeError("Cannot register with a key of None") - for key, value in kwargs.items(): - - if type(value) not in (list, str): - raise TypeError( - "Expected list or string, got %s" % type(value)) - elif type(value) is list: - for name in value: - - value_mapping[name] = obj - else: - value_mapping[key] = {str(value): obj} - - if self._registry.get(key): - value_mapping[key] = self._registry[key] - value_mapping[key][str(value)] = obj - - self._registry[key] = value_mapping[key] - - def find(self, **kwargs): - - for key, value in kwargs.items(): - if len(kwargs) > 1: - yield self._registry[key][value] - - return self._registry[key][value] - - def remove(self, *args, **kwargs): - - _ex = [] - - if not len(args) < 2: - for x in args: - self.remove(x) - - elif isinstance(args[0], Iterable): - for x in args[0]: - if isinstance(x, dict): - raise TypeError("Improper type %S" % type(x)) - self.remove(x) - elif len(args) is 1: - inverse = {} - for x, y in self._registry.items(): - if isinstance(y, dict): - for k, v in y.items(): - if args[0] is v: - try: - del self._registry[k] - except IndexError as ex: - _ex.append(ex) - continue - - for key, value in kwargs.items(): - try: - self.remove(self._registry[key][value]) - except IndexError as ex: - _ex.append(ex) - - # Dunno what to do with all the exceptions - # Return em! - return _ex if _ex else None - - -class CodecRegistry(GenericRegistry): - +from .registry import Registry + + +# class Codec: +# +# """A wrapper for a codec entered into the CodecRegistry. +# """ +# +# def __init__(self, fn, names, mime_types): +# self.transcode = fn +# self.names = names +# self.mime_types = mime_types +# +# def __call__(self, *args, **kwargs): +# return self.transcode(*args, **kwargs) +# +# def __eq__(self, other): +# try: +# # Compare our contained function with the passed function or +# # the passed container's function. +# return (self.transcode == other or +# self.transcode == other.transcode) +# +# except AttributeError: +# return NotImplemented + + +class CodecRegistry(Registry): """A registry used for registering and removing encoders and decoders. """ - def __init__(self, Wrapper=Codec): - - # Codec registry. Maps names to codecs - self._codecs = {} - - # Mime-type registry. Maps mime-types to codecs - self._mime_types = {} + # def __init__(self):#, Wrapper=Codec): + # super().__init__() - # Codec wrapper constructor. Returns a wrapped version of the codec + # Codec wrapper constructor. Returns a wrapped version of the codec # Invoking the wrapper should invoke the codec. - self._Wrapper = Wrapper + # self._Wrapper = Wrapper - def find(self, *, media_range=None, name=None, mime_type=None): # noqa - """ - Attempt to find a compliant codec given either a mime_type or - codec name. Prioritize the mime-type, then name, then media_range. - """ - - if mime_type is not None: - return self._mime_types[mime_type] - - if name is not None: - return self._codecs[name] - - if media_range is not None: - return self._find_media_range(media_range) - - raise TypeError( - 'At least one parameter is required: ' - 'media_range, mime_type, or name.') - - def _find_media_range(self, media_range): + def find_media_range(self, media_range): try: # best_match returns empty string on failure to find a match. # The correct KeyError will be thrown when attempting the lookup # below. - found = mimeparse.best_match(self._mime_types.keys(), media_range) + found = mimeparse.best_match(self.map["mime_type"].keys(), + media_range) except ValueError: raise KeyError('Malformed media range.') - return self._mime_types[found] - - def register(self, names=(), mime_types=(), **kwargs): - """Register the transcoder provided in the global list of transcoders. - This is invoked as a decorator for the encoding function being - registered. - - EX: - - @register(names=['example'], mime_types=['example/example']) - def example_codec(): - return dostuff() - """ - def wrapper(fn): - # Register the function! - self._register(fn, names, mime_types, **kwargs) - - # We can just return the encoder directly. - # We're only registering it. - return fn - return wrapper - - def _register(self, codec_fn, names=(), mime_types=(), **kwargs): - """Register the transcoder provided in the global list of transcoders. - """ - - # Sanity check. - assert len(names) or len(mime_types), ( - "Encoder/Decoder cannot be registered without at least one of " - "'names' or 'mime_types'") - - codec = self._Wrapper(codec_fn, names, mime_types, **kwargs) - - self._codecs.update({x: codec for x in names}) - self._mime_types.update({x: codec for x in mime_types}) - - def remove(self, codec=None, *, name=None, mime_type=None): - """Remove the codec from the global list of available codecs. - - Attempts to match the codec by name or mime_type if passed (but - prioritizes codec if passed). - """ - - if codec is not None: - for registry in (self._codecs, self._mime_types): - for name, test in list(registry.items()): - if codec == test: - del registry[name] - - if name is not None: - self.remove(self._codecs.get(name)) - - if mime_type is not None: - self.remove(self._mime_types.get(mime_type)) + # Push the resolved mime_type into the `find` method. + return self.find(mime_type=found) + + # def _register(self, codec_fn, names=(), mime_types=(), **kwargs): + # """Register the transcoder provided in the global list of transcoders. + # """ + # + # # Sanity check. + # assert len(names) or len(mime_types), ( + # "Encoder/Decoder cannot be registered without at least one of " + # "'names' or 'mime_types'") + # + # codec = self._Wrapper(codec_fn, names, mime_types, **kwargs) + # + # self._codecs.update({x: codec for x in names}) + # self._mime_types.update({x: codec for x in mime_types}) class URLCodec: diff --git a/armet/decoders.py b/armet/decoders.py deleted file mode 100644 index d33b95a..0000000 --- a/armet/decoders.py +++ /dev/null @@ -1,59 +0,0 @@ -# from .codecs import CodecRegistry -from . import codecs -import urllib.parse -import json -import io -import cgi -import operator - - -# Create our encoder registry and pull methods off it for easy access. -_registry = codecs.CodecRegistry() - -find = _registry.find -remove = _registry.remove -register = _registry.register - - -@register( - names=codecs.URLCodec.names, - mime_types=codecs.URLCodec.mime_types) -def url_decode(text): - try: - data = urllib.parse.parse_qs(text) - return {k: v[0] if len(v) == 1 else v for k, v in data.items()} - - except AttributeError as ex: - raise TypeError from ex - - -@register( - names=codecs.JSONCodec.names, - mime_types=codecs.JSONCodec.mime_types) -def json_decode(text): - try: - return json.loads(text) - except ValueError as ex: - raise TypeError from ex - - -@register( - names=codecs.FormDataCodec.names, - mime_types=codecs.FormDataCodec.mime_types) -def form_decode(text, boundary): - fp = io.BytesIO(text) - result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) - - # We need to operate on the values to decode them and to unpack shallow - # ones. - keys, values = zip(*result.items()) - - # Decode the values - decode = operator.methodcaller('decode', 'utf8') - values = (list(map(decode, entry)) for entry in values) - - # Unpack shallow values (where there's only one) - values = (x if len(x) > 1 else x[0] for x in values) - - # Return the dictionary! - return dict(zip(keys, values)) diff --git a/armet/decoders/__init__.py b/armet/decoders/__init__.py new file mode 100644 index 0000000..5bbce02 --- /dev/null +++ b/armet/decoders/__init__.py @@ -0,0 +1,21 @@ +from ..codecs import CodecRegistry + + +__all__ = [ + "find", + "remove", + "register" +] + + +# Construct a module-scoped registry for the decoders. +_registry = CodecRegistry() + +# Take the general methods and attach to the module for easy access. +find = _registry.find +remove = _registry.remove +register = _registry.register + + +# Import the builtin decoders (which can be overriden by a user). +from . import json, url # noqa diff --git a/armet/decoders/json.py b/armet/decoders/json.py new file mode 100644 index 0000000..6d2f7aa --- /dev/null +++ b/armet/decoders/json.py @@ -0,0 +1,14 @@ +import json + +from ..codecs import JSONCodec +from . import register + + +# @register(name="json", mime_types=JSONCodec.mime_types) +@register(name="json", mime_type="application/json") +def decode(text): + try: + return json.loads(text) + + except ValueError as ex: + raise TypeError from ex diff --git a/armet/decoders/url.py b/armet/decoders/url.py new file mode 100644 index 0000000..c3ebbba --- /dev/null +++ b/armet/decoders/url.py @@ -0,0 +1,16 @@ +import json +from urllib.parse import parse_qs + +from ..codecs import URLCodec +from . import register + + +# @register(name="url", mime_type=URLCodec.mime_types) +@register(name="url", mime_type=list(URLCodec.mime_types)[0]) +def decode(text): + try: + data = parse_qs(text) + return {k: v[0] if len(v) == 1 else v for k, v in data.items()} + + except AttributeError as ex: + raise TypeError from ex diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py index 84b1ac8..2a2c107 100644 --- a/armet/encoders/__init__.py +++ b/armet/encoders/__init__.py @@ -1,12 +1,21 @@ -from .general import register, remove, find, url_encoder, json_encoder -from .form import form_encoder +from ..codecs import CodecRegistry __all__ = [ - 'register', - 'remove', - 'find', - 'url_encoder', - 'json_encoder', - 'form_encoder', + "find", + "remove", + "register" ] + + +# Construct a module-scoped registry for the encoders. +_registry = CodecRegistry() + +# Take the general methods and attach to the module for easy access. +find = _registry.find +remove = _registry.remove +register = _registry.register + + +# Import the builtin encoders (which can be overriden by a user). +from . import json, url # noqa diff --git a/armet/encoders/form.py b/armet/encoders/form.py deleted file mode 100644 index 3f44bfe..0000000 --- a/armet/encoders/form.py +++ /dev/null @@ -1,110 +0,0 @@ -import uuid -import collections -from io import BytesIO -from .general import register -from armet.codecs import FormDataCodec - - -def generate_boundary(): - """http://xkcd.com/221/""" - return uuid.uuid4().hex - - -def generate_encoder(encoding): - def retfn(string): - return string.encode(encoding) - return retfn - - -# Form data header sample: -# Content-Type: multipart/form-data; boundary=AaB03x - -# The form data encoder should spit out something like this: -# --AaB03x -# Content-Disposition: form-data; name="submit-name" -# -# Larry -# --AaB03x -# Content-Disposition: form-data; name="files"; filename="file1.txt" -# Content-Type: text/plain -# -# ... contents of file1.txt ... -# --AaB03x-- - - -def segment_stream(cache, buf, segment_size=16*1024): - """Yields bytes in `segment_size` chunks from cache and then from buf. - Operates by writing buf to cache and cleaning it every once in a while. - Expects cache to be a BytesIO and buf to be a bytes. - """ - while True: - size = cache.tell() - add = buf[:segment_size-size] - buf = buf[segment_size-size:] - cache.write(add) - - value = cache.getvalue() - - # Reset the cache. - cache.truncate(0) - cache.seek(0) - - if not value: - break - - yield value - - -@register( - names=FormDataCodec.names, - mime_types=FormDataCodec.mime_types, - preferred_mime_type=FormDataCodec.preferred_mime_type) -def form_encoder(data, encoding): - """Expects to recieve a data structure of the following form: - {name: value, name: [value1, value2]} - """ - - buf = BytesIO() - encode = generate_encoder(encoding) - boundary = encode(generate_boundary()) - - # This function uses a lot of + to join strings, - # this is because python3 does not allow for b'%s' % bytes() - - # Sanity check. - if not isinstance(data, collections.Mapping): - raise TypeError - - for name, values in data.items(): - # Normalize the values. - if isinstance(values, str): - values = (values,) - elif not isinstance(values, collections.Sequence): - # Sanity check. - raise TypeError - - for entry in values: - # Write the boundary for the next entry. - buf.write(b'--' + boundary + b'\r\n') - - # Write the header for the next entry. - # Note the extra new line afterwards. This is because the next - # entry is the value. - bounds = ( - b'Content-Disposition: form-data; name=' + - encode(name) + - b'\r\n\r\n') - - buf.write(bounds) - - # Write the contents. - # TODO: Make this stream the contents of entry - yield from segment_stream(buf, encode(entry)) - - # Write the trailing newline - buf.write(b'\r\n') - - # All done. - buf.write(b'--' + boundary + b'--') - - yield from segment_stream(buf, b'') diff --git a/armet/encoders/general.py b/armet/encoders/general.py deleted file mode 100644 index 121a91a..0000000 --- a/armet/encoders/general.py +++ /dev/null @@ -1,77 +0,0 @@ -from armet import codecs -from itertools import chain, repeat -from urllib.parse import urlencode -from collections import Iterable -import json - - -class Encoder(codecs.Codec): - """Wraps encoders to provide additonal decorator functionality.""" - - def __init__(self, *args, preferred_mime_type=None, **kwargs): - super().__init__(*args, **kwargs) - # Make an attempt to get a random preferred mime type. - # Fallback to plain text if no mimetypes were defined for this. - if preferred_mime_type is None: - preferred_mime_type = next(iter(self.mime_types), 'text/plain') - self.preferred_mime_type = preferred_mime_type - -# Create our encoder registry and pull methods off it for easy access. -_registry = codecs.CodecRegistry(Encoder) - -find = _registry.find -remove = _registry.remove -register = _registry.register - - -def _chunk(data, chunk_size=16*1024): - """Simple chunking function to easily make encoders into generators. - - Invocations of this should be replaced when more streaming-friendly - encoders are implemented. - """ - while True: - buf = data[:chunk_size] - data = data[chunk_size:] - if not buf: - break - yield buf - - -@register( - names=codecs.URLCodec.names, - mime_types=codecs.URLCodec.mime_types, - preferred_mime_type=codecs.URLCodec.preferred_mime_type) -def url_encoder(data, encoding): - try: - # Normalize the encode so that users pay invoke using either - # {"foo": "bar"} or {"foo": ["bar", "baz"]}. - data = list(chain.from_iterable( - ((k, v),) if isinstance(v, str) else zip(repeat(k), v) - for k, v in data.items())) - - except AttributeError as ex: - raise TypeError from ex - - # URL encode it and return a streamable generator. - # TODO: Replace this with actual url encoded streaming. - return _chunk(urlencode(data).encode(encoding)) - - -@register( - names=codecs.JSONCodec.names, - mime_types=codecs.JSONCodec.mime_types, - preferred_mime_type=codecs.JSONCodec.preferred_mime_type) -def json_encoder(data, encoding): - # Ensure that the scalar data is wrapped in a list as - # a valid JSON document must be an object or a list. - # See: http://tools.ietf.org/html/rfc4627 - if isinstance(data, str) or not isinstance(data, Iterable): - data = [data] - - # Separators are used here to assert that no uneccesary spaces are - # added to the json. - data = json.dumps(data, separators=(',', ':')) - - # TODO: Replace this with real json streaming - return _chunk(data.encode(encoding)) diff --git a/armet/encoders/json.py b/armet/encoders/json.py new file mode 100644 index 0000000..8448b7d --- /dev/null +++ b/armet/encoders/json.py @@ -0,0 +1,23 @@ +import json +from collections import Iterable + +from ..codecs import JSONCodec +from ..utils import chunk +from . import register + + +# @register(name="json", mime_types=JSONCodec.mime_types) +@register(name="json", mime_type="application/json") +def encode(data, encoding): + # Ensure that the scalar data is wrapped in a list as + # a valid JSON document be an object or a list. + # See: http://tools.ietf.org/html/rfc4627 + if isinstance(data, str) or not isinstance(data, Iterable): + data = [data] + + # Separators are used here to assert that no uneccesary spaces are + # added to the json. + data = json.dumps(data, separators=(',', ':')) + + # TODO: Replace this with real json streaming + return chunk(data.encode(encoding)) diff --git a/armet/encoders/url.py b/armet/encoders/url.py new file mode 100644 index 0000000..c84b82b --- /dev/null +++ b/armet/encoders/url.py @@ -0,0 +1,25 @@ +import json +from itertools import chain, repeat +from urllib.parse import urlencode + +from ..codecs import URLCodec +from ..utils import chunk +from . import register + + +# @register(name="url", mime_type=URLCodec.mime_types) +@register(name="url", mime_type=URLCodec.preferred_mime_type) +def encode(data, encoding): + try: + # Normalize the encode so that users pay invoke using either + # {"foo": "bar"} or {"foo": ["bar", "baz"]}. + data = list(chain.from_iterable( + ((k, v),) if isinstance(v, str) else zip(repeat(k), v) + for k, v in data.items())) + + except AttributeError as ex: + raise TypeError from ex + + # URL encode it and return a streamable generator. + # TODO: Replace this with actual url encoded streaming. + return chunk(urlencode(data).encode(encoding)) diff --git a/armet/registry.py b/armet/registry.py index 85365b0..14c5122 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -4,15 +4,27 @@ class Registry: def __init__(self): - self._registry = defaultdict(dict) + self.map = defaultdict(dict) + + def register(self, obj=None, **kwargs): + + def callback(obj): + if obj is None: + raise TypeError("'%s' object cannot be registered" % + type(obj).__name__) + + for key, value in kwargs.items(): + self.map[key][value] = obj + + return obj - def register(self, obj, **kwargs): if obj is None: - raise TypeError("'%s' object cannot be registered" % - type(obj).__name__) + # If no object was passed in we assume the user is attempting + # to use this as a decorator. + return callback - for key, value in kwargs.items(): - self._registry[key][value] = obj + # Just invoke the callback directly + callback(obj) def find(self, **kwargs): if len(kwargs) > 1: @@ -21,8 +33,18 @@ def find(self, **kwargs): type(self).__name__, len(kwargs))) try: + # Pop the (key, value) pair to pass to the lookup method. key, value = kwargs.popitem() - return self._registry[key][value] + + # Resolve a `find_FOO` method. + # The idea here is that a derived class could define + # a custom lookup method for a specific attribute. + lookup = getattr(self, "find_%s" % key, + lambda v: self.map[key][v]) + + # Utilize the lookup method to attempt to find the object + # by the passed value. + return lookup(value) except KeyError: # If we don't find what they were looking for; return nothing. @@ -32,19 +54,19 @@ def remove(self, *args, **kwargs): # For each passed object we need to iterate through each nested # dictionary and remove each reference to it. for obj in args: - for registry_name, registry in list(self._registry.items()): + for registry_name, registry in list(self.map.items()): for name, item in list(registry.items()): if item is obj: del registry[name] if not registry: - del self._registry[registry_name] + del self.map[registry_name] # For each passed reference we retrieve the object at the reference # and recurse into removing every reference for that object. for key, value in kwargs.items(): try: - self.remove(self._registry[key][value]) + self.remove(self.map[key][value]) except IndexError: pass diff --git a/armet/utils.py b/armet/utils.py index b09dbfc..99b16f1 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -11,3 +11,17 @@ def dasherize(text): result = result[1:] return result + + +def chunk(data, chunk_size=16*1024): + """Simple chunking function to easily make encoders into generators. + + Invocations of this should be replaced when more streaming-friendly + encoders are implemented. + """ + while True: + buf = data[:chunk_size] + data = data[chunk_size:] + if not buf: + break + yield buf diff --git a/tests/test_api.py b/tests/test_api.py index e834ccc..713d51d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,17 +2,12 @@ from armet.resources import Resource from armet import encoders, decoders from unittest import mock +from pytest import mark class TestAPI(RequestTest): - def setup(self): - # Register a dummy encoder and decoder. - self.codec = mock.MagicMock() - self.codec.return_value = b'test' - encoders.register(names=['test'], mime_types=['test/test'])(self.codec) - decoders.register(names=['test'], mime_types=['test/test'])(self.codec) - + @mark.xfail def test_route_resource(self): # Create and register some resources. to test api routing. retval = [{'foo': 'bar'}] @@ -25,14 +20,11 @@ def test_route_resource(self): self.app.register(route_resource, name='route') self.app.register(get_resource, name='read') - # Test routing to those resources. - headers = {'Accept': 'test/test', 'Content-Type': 'test/test'} - - response = self.get('/read', headers=headers) + response = self.get('/read') assert response.status_code == 200 assert get_resource().read.called - response = self.get('/route', headers=headers) + response = self.get('/route') assert response.status_code == 200 assert route_resource().read.called diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 56b09d6..4085df3 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,4 +1,5 @@ import pytest +from pytest import mark from armet.codecs import CodecRegistry @@ -10,6 +11,7 @@ class CounterExampleEncoder: pass +@mark.xfail class TestCodecRegistry: def setup(self): diff --git a/tests/test_decoders.py b/tests/test_decoders.py index c4fff7e..6277dde 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -51,6 +51,7 @@ def test_decode_failure(self): self.decode('fail') +@mark.xfail @mark.bench('self.decode', iterations=10000) class TestFormDecoder: diff --git a/tests/test_encoders.py b/tests/test_encoders.py index c1b91e3..cb76105 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -14,6 +14,7 @@ def test_encoders_api_methods(): assert encoders.remove +@mark.xfail @mark.bench("encoders.register") class TestEncoderRegisterDecorator: @@ -110,6 +111,7 @@ def test_encode_failure(self): self.encode({'foo': range(10)}) +@mark.xfail @mark.bench('self.encoder', iterations=10000) class TestFormDataEncoder(BaseEncoderTest): From 6a3337c26546b6840b64929560d1bc413e83e1b3 Mon Sep 17 00:00:00 2001 From: Pholey Date: Thu, 10 Jul 2014 14:49:49 -0700 Subject: [PATCH 079/118] mid test --- tests/test_registry.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/test_registry.py diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..6925214 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,17 @@ +from armet.registry import Registry + + +class TestRegistry: + + def setup(self): + self.registry = Registry() + for x in range(10): + self.registry.register("obj_%d" % x, name="test_%d" % x) + + def test_register_with_same_name(self): + reg = self.registry + + reg.register("") + + + From f042910128af514d1f656c1cbb9f94ebb3df85b2 Mon Sep 17 00:00:00 2001 From: Pholey Date: Thu, 10 Jul 2014 16:42:08 -0700 Subject: [PATCH 080/118] Stopping point for a second --- tests/test_registry.py | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/test_registry.py b/tests/test_registry.py index 6925214..f561218 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,17 +1,47 @@ from armet.registry import Registry +import pytest class TestRegistry: def setup(self): self.registry = Registry() - for x in range(10): + for x in range(5): self.registry.register("obj_%d" % x, name="test_%d" % x) + self.registry.register("obj_%d" % x, name2="test_%d" % x) - def test_register_with_same_name(self): - reg = self.registry + def test_register_and_find(self): - reg.register("") + # Test that fancy new register decorator + @self.registry.register(name="test_12") + def test_registry_decorator(): + return "Example_obj" + self.registry.register("Example_obj2", name="test_13") + # test_registry_decorator() + assert self.registry.find(name="test_12") is test_registry_decorator + + def test_raise_registry_exceptions(self): + # with pytest.raises(TypeError): + # @self.registry.register(None, name="not_valid") + + with pytest.raises(TypeError): + self.registry.find(name="this", multiple_kwargs="not_valid") + + # Test the not-found exception in find, raises key-error, returns None. + assert self.registry.find(some_property="non_existant") is None + + def test_remove(self): + # Test removing with just the object + # self.registry.remove("obj_1") + # assert self.registry.find(name="test_1") is None + # # assert that every single reference is removed + # assert self.registry.find(name2="test_1") is None + + # Test removing with just kwargs: + self.registry.remove(name="test_2") + assert self.registry.find(name="test_2") is None + # assert that every single reference is removed + assert self.registry.find(name2="test_2") is None From 49a9d8a02648cd2d6bb4fcdc5f2159c91e2b7a98 Mon Sep 17 00:00:00 2001 From: Pholey Date: Thu, 10 Jul 2014 17:38:56 -0700 Subject: [PATCH 081/118] fixed small bug in registry, 100% test coverage on Registry --- armet/registry.py | 4 ++-- tests/test_registry.py | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/armet/registry.py b/armet/registry.py index 14c5122..0093d3c 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -56,7 +56,7 @@ def remove(self, *args, **kwargs): for obj in args: for registry_name, registry in list(self.map.items()): for name, item in list(registry.items()): - if item is obj: + if item == obj: del registry[name] if not registry: @@ -68,5 +68,5 @@ def remove(self, *args, **kwargs): try: self.remove(self.map[key][value]) - except IndexError: + except KeyError: pass diff --git a/tests/test_registry.py b/tests/test_registry.py index f561218..0045b59 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -24,8 +24,8 @@ def test_registry_decorator(): assert self.registry.find(name="test_12") is test_registry_decorator def test_raise_registry_exceptions(self): - # with pytest.raises(TypeError): - # @self.registry.register(None, name="not_valid") + with pytest.raises(TypeError): + self.registry.register(name="not_valid")(None) with pytest.raises(TypeError): self.registry.find(name="this", multiple_kwargs="not_valid") @@ -35,13 +35,29 @@ def test_raise_registry_exceptions(self): def test_remove(self): # Test removing with just the object - # self.registry.remove("obj_1") - # assert self.registry.find(name="test_1") is None - # # assert that every single reference is removed - # assert self.registry.find(name2="test_1") is None + self.registry.remove("obj_1") + assert self.registry.find(name="test_1") is None + # assert that every single reference is removed + assert self.registry.find(name2="test_1") is None # Test removing with just kwargs: self.registry.remove(name="test_2") + assert self.registry.find(name="test_2") is None # assert that every single reference is removed assert self.registry.find(name2="test_2") is None + + self.registry.remove(name="test_3", name2="test_3") + + assert self.registry.find(name2="test_3") is None + + self.registry.remove() + + # Test removing the entire "test" key if it holds no items + self.registry.register("obj_1", test="test_1") + self.registry.remove(test="test_1") + + assert self.registry.map.get("test") is None + + # Test removing a non-existing value + self.registry.remove(non_existant="value") From 9cb68b826ae4a576f889636aad08a624bb957561 Mon Sep 17 00:00:00 2001 From: Pholey Date: Thu, 10 Jul 2014 17:42:20 -0700 Subject: [PATCH 082/118] remove comment bloat --- armet/codecs.py | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/armet/codecs.py b/armet/codecs.py index bc99fd9..dd0327c 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -1,43 +1,11 @@ import mimeparse -from collections import Iterable from .registry import Registry -# class Codec: -# -# """A wrapper for a codec entered into the CodecRegistry. -# """ -# -# def __init__(self, fn, names, mime_types): -# self.transcode = fn -# self.names = names -# self.mime_types = mime_types -# -# def __call__(self, *args, **kwargs): -# return self.transcode(*args, **kwargs) -# -# def __eq__(self, other): -# try: -# # Compare our contained function with the passed function or -# # the passed container's function. -# return (self.transcode == other or -# self.transcode == other.transcode) -# -# except AttributeError: -# return NotImplemented - - class CodecRegistry(Registry): """A registry used for registering and removing encoders and decoders. """ - # def __init__(self):#, Wrapper=Codec): - # super().__init__() - - # Codec wrapper constructor. Returns a wrapped version of the codec - # Invoking the wrapper should invoke the codec. - # self._Wrapper = Wrapper - def find_media_range(self, media_range): try: # best_match returns empty string on failure to find a match. @@ -52,20 +20,6 @@ def find_media_range(self, media_range): # Push the resolved mime_type into the `find` method. return self.find(mime_type=found) - # def _register(self, codec_fn, names=(), mime_types=(), **kwargs): - # """Register the transcoder provided in the global list of transcoders. - # """ - # - # # Sanity check. - # assert len(names) or len(mime_types), ( - # "Encoder/Decoder cannot be registered without at least one of " - # "'names' or 'mime_types'") - # - # codec = self._Wrapper(codec_fn, names, mime_types, **kwargs) - # - # self._codecs.update({x: codec for x in names}) - # self._mime_types.update({x: codec for x in mime_types}) - class URLCodec: From 7db5ad208aa07e239e56ee85dfd97b6567eb8db2 Mon Sep 17 00:00:00 2001 From: Pholey Date: Thu, 10 Jul 2014 17:49:38 -0700 Subject: [PATCH 083/118] fix pep-8 error --- armet/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 4a12206..654b605 100644 --- a/armet/api.py +++ b/armet/api.py @@ -53,7 +53,7 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): # noqa + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if name is None: # Convert the name of the handler to dash-case From fff8516168ec50eb1c7b4afa3bead6dc5cc6e5bb Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:22:55 -0700 Subject: [PATCH 084/118] Allow values to be iterable. --- armet/registry.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/armet/registry.py b/armet/registry.py index 0093d3c..de1a17a 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -1,4 +1,4 @@ -from collections import defaultdict +from collections import defaultdict, Iterable class Registry: @@ -14,7 +14,12 @@ def callback(obj): type(obj).__name__) for key, value in kwargs.items(): - self.map[key][value] = obj + if isinstance(value, Iterable) and not isinstance(value, str): + for val in value: + self.map[key][val] = obj + + else: + self.map[key][value] = obj return obj From 6e31c9dc3830f04006947eddffe8f5000433d5ba Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 10:24:33 -0700 Subject: [PATCH 085/118] Where is the CMO when you need him. --- armet/api.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 72881f9..08ff490 100644 --- a/armet/api.py +++ b/armet/api.py @@ -8,11 +8,23 @@ class Api: +<<<<<<< Updated upstream def __init__(self, trailing_slash=False, debug=False): +======= + def __init__(self, trailing_slash=False, debug=False, expose=True, name=None): +>>>>>>> Stashed changes # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} +<<<<<<< Updated upstream +======= + self.name = name + + # An attribute to disallow direct routing to a resource + # default is true. + self.expose = expose +>>>>>>> Stashed changes # Set if we're in debugging mode. self.debug = debug @@ -52,7 +64,9 @@ def teardown(self): def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. - if name is None: + if handler.name is not None: + name = handler.name + elif name is None: # Convert the name of the handler to dash-case name = utils.dasherize(handler.__name__) if name.endswith("-resource"): From 269dfec047f360d3152198ff6296f692319713a1 Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 10:27:46 -0700 Subject: [PATCH 086/118] i hope i didn't break anything --- armet/api.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/armet/api.py b/armet/api.py index 08ff490..8be497e 100644 --- a/armet/api.py +++ b/armet/api.py @@ -8,23 +8,16 @@ class Api: -<<<<<<< Updated upstream - def __init__(self, trailing_slash=False, debug=False): -======= - def __init__(self, trailing_slash=False, debug=False, expose=True, name=None): ->>>>>>> Stashed changes + def __init__(self, trailing_slash=False, debug=False, expose=True, + name=None): # TODO: Should this be that `Registry` thing we were talking about? # That would give us the `remove` functionality easily self._registry = {} - -<<<<<<< Updated upstream -======= self.name = name # An attribute to disallow direct routing to a resource # default is true. self.expose = expose ->>>>>>> Stashed changes # Set if we're in debugging mode. self.debug = debug @@ -62,7 +55,7 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if handler.name is not None: name = handler.name From 62b73988ff90ac22054f1492d1d4b075baf1c5c8 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:33:03 -0700 Subject: [PATCH 087/118] Update; put back form. --- armet/codecs.py | 2 +- armet/decoders/__init__.py | 2 +- armet/decoders/form.py | 28 ++++++++++++ armet/decoders/json.py | 3 +- armet/decoders/url.py | 4 +- armet/encoders/__init__.py | 2 +- armet/encoders/form.py | 93 ++++++++++++++++++++++++++++++++++++++ tests/test_decoders.py | 1 - tests/test_encoders.py | 30 ------------ 9 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 armet/decoders/form.py create mode 100644 armet/encoders/form.py diff --git a/armet/codecs.py b/armet/codecs.py index dd0327c..e9877b4 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -52,7 +52,7 @@ class JSONCodec: names = {'json'} -class FormDataCodec: +class FormCodec: preferred_mime_type = 'multipart/form-data' diff --git a/armet/decoders/__init__.py b/armet/decoders/__init__.py index 5bbce02..2f3c965 100644 --- a/armet/decoders/__init__.py +++ b/armet/decoders/__init__.py @@ -18,4 +18,4 @@ # Import the builtin decoders (which can be overriden by a user). -from . import json, url # noqa +from . import json, url, form # noqa diff --git a/armet/decoders/form.py b/armet/decoders/form.py new file mode 100644 index 0000000..7699471 --- /dev/null +++ b/armet/decoders/form.py @@ -0,0 +1,28 @@ +import cgi +import operator +import io + +from ..codecs import FormCodec +from . import register + + +# FIXME: This expects a "boundary" property that it won't get, ever. +# FIXME: This is quite slow (guess is that is because of "cgi") +@register(name="form", mime_type=FormCodec.mime_types) +def decode(text, boundary): + fp = io.BytesIO(text) + result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) + + # We need to operate on the values to decode them and to unpack shallow + # ones. + keys, values = zip(*result.items()) + + # Decode the values + decode = operator.methodcaller('decode', 'utf8') + values = (list(map(decode, entry)) for entry in values) + + # Unpack shallow values (where there's only one) + values = (x if len(x) > 1 else x[0] for x in values) + + # Return the dictionary! + return dict(zip(keys, values)) diff --git a/armet/decoders/json.py b/armet/decoders/json.py index 6d2f7aa..4cf0d62 100644 --- a/armet/decoders/json.py +++ b/armet/decoders/json.py @@ -4,8 +4,7 @@ from . import register -# @register(name="json", mime_types=JSONCodec.mime_types) -@register(name="json", mime_type="application/json") +@register(name="json", mime_type=JSONCodec.mime_types) def decode(text): try: return json.loads(text) diff --git a/armet/decoders/url.py b/armet/decoders/url.py index c3ebbba..6dfd2f2 100644 --- a/armet/decoders/url.py +++ b/armet/decoders/url.py @@ -1,12 +1,10 @@ -import json from urllib.parse import parse_qs from ..codecs import URLCodec from . import register -# @register(name="url", mime_type=URLCodec.mime_types) -@register(name="url", mime_type=list(URLCodec.mime_types)[0]) +@register(name="url", mime_type=URLCodec.mime_types) def decode(text): try: data = parse_qs(text) diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py index 2a2c107..5b5e6f2 100644 --- a/armet/encoders/__init__.py +++ b/armet/encoders/__init__.py @@ -18,4 +18,4 @@ # Import the builtin encoders (which can be overriden by a user). -from . import json, url # noqa +from . import json, url, form # noqa diff --git a/armet/encoders/form.py b/armet/encoders/form.py new file mode 100644 index 0000000..ec2790b --- /dev/null +++ b/armet/encoders/form.py @@ -0,0 +1,93 @@ +import uuid +import collections +from io import BytesIO + +from ..codecs import FormCodec +from . import register + + +# FIXME: Should use the make_boundary function from the stdlib +def generate_boundary(): + """http://xkcd.com/221/""" + return uuid.uuid4().hex + + +def generate_encoder(encoding): + def retfn(string): + return string.encode(encoding) + return retfn + + +# FIXME: I don't understand the point of this (why not just utils.chunk) +def segment_stream(cache, buf, segment_size=16*1024): + """Yields bytes in `segment_size` chunks from cache and then from buf. + Operates by writing buf to cache and cleaning it every once in a while. + Expects cache to be a BytesIO and buf to be a bytes. + """ + while True: + size = cache.tell() + add = buf[:segment_size-size] + buf = buf[segment_size-size:] + cache.write(add) + + value = cache.getvalue() + + # Reset the cache. + cache.truncate(0) + cache.seek(0) + + if not value: + break + + yield value + + +@register(name="form", mime_type=FormCodec.mime_types) +def encode(data, encoding): + """Expects to recieve a data structure of the following form: + {name: value, name: [value1, value2]} + """ + buf = BytesIO() + encode = generate_encoder(encoding) + boundary = encode(generate_boundary()) + + # This function uses a lot of + to join strings, + # this is because python3 does not allow for b'%s' % bytes() + + # Sanity check. + if not isinstance(data, collections.Mapping): + raise TypeError + + for name, values in data.items(): + # Normalize the values. + if isinstance(values, str): + values = (values,) + elif not isinstance(values, collections.Sequence): + # Sanity check. + raise TypeError + + for entry in values: + # Write the boundary for the next entry. + buf.write(b'--' + boundary + b'\r\n') + + # Write the header for the next entry. + # Note the extra new line afterwards. This is because the next + # entry is the value. + bounds = ( + b'Content-Disposition: form-data; name=' + + encode(name) + + b'\r\n\r\n') + + buf.write(bounds) + + # Write the contents. + # TODO: Make this stream the contents of entry + yield from segment_stream(buf, encode(entry)) + + # Write the trailing newline + buf.write(b'\r\n') + + # All done. + buf.write(b'--' + boundary + b'--') + + yield from segment_stream(buf, b'') diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 6277dde..c4fff7e 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -51,7 +51,6 @@ def test_decode_failure(self): self.decode('fail') -@mark.xfail @mark.bench('self.decode', iterations=10000) class TestFormDecoder: diff --git a/tests/test_encoders.py b/tests/test_encoders.py index cb76105..4bcc083 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -14,35 +14,6 @@ def test_encoders_api_methods(): assert encoders.remove -@mark.xfail -@mark.bench("encoders.register") -class TestEncoderRegisterDecorator: - - def test_register(self): - mime = 'application/test' - args = { - 'names': ['test'], - 'mime_types': [mime], - 'preferred_mime_type': mime, - } - - @encoders.register(**args) - def encoder_test(data, encoding): - yield json.dumps(data).encode(encoding) - - encoder = encoders.find(name='test') - assert encoder == encoder_test - assert encoder.preferred_mime_type == mime - - def test_preferred_mime_type_fallback(self): - @encoders.register(names=['test']) - def encoder_test(data, encoding): - yield json.dumps(data).encode(encoding) - - encoder = encoders.find(name='test') - assert encoder.preferred_mime_type == 'text/plain' - - class BaseEncoderTest: def encode(self, data): @@ -111,7 +82,6 @@ def test_encode_failure(self): self.encode({'foo': range(10)}) -@mark.xfail @mark.bench('self.encoder', iterations=10000) class TestFormDataEncoder(BaseEncoderTest): From 2d5e7db9f6f5e55ee97dc4338bba0a5cf1b7a812 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:40:20 -0700 Subject: [PATCH 088/118] Test more of the users of 'utils.dasherize' --- armet/utils.py | 2 ++ tests/test_utils.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/armet/utils.py b/armet/utils.py index 99b16f1..81f8971 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -4,6 +4,8 @@ def dasherize(text): for item in text: if item.isupper(): result += "-" + item.lower() + elif item == "_": + result += "-" else: result += item diff --git a/tests/test_utils.py b/tests/test_utils.py index ab5cec0..121705c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,10 +2,25 @@ from armet import utils -@mark.bench("utils.dasherize", iterations=1000000) +@mark.bench("utils.dasherize", iterations=100000) class TestDasherize: - def test_simple(self): + def test_from_pascal_case(self): text = utils.dasherize("ContactPhoto") assert text == "contact-photo" + + def test_from_camel_case(self): + text = utils.dasherize("contactPhoto") + + assert text == "contact-photo" + + def test_from_underscores(self): + text = utils.dasherize("contact_photo") + + assert text == "contact-photo" + + def test_from_dashed(self): + text = utils.dasherize("contact-photo") + + assert text == "contact-photo" From 1f4647fcfaf2845938170e27e0d9b835d5e2f81e Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:45:17 -0700 Subject: [PATCH 089/118] Split up tests for test_registry. --- tests/test_registry.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/test_registry.py b/tests/test_registry.py index 0045b59..fab7cd5 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -10,40 +10,39 @@ def setup(self): self.registry.register("obj_%d" % x, name="test_%d" % x) self.registry.register("obj_%d" % x, name2="test_%d" % x) - def test_register_and_find(self): - - # Test that fancy new register decorator + def test_register_decorator(self): @self.registry.register(name="test_12") def test_registry_decorator(): - return "Example_obj" - - self.registry.register("Example_obj2", name="test_13") - - # test_registry_decorator() + pass assert self.registry.find(name="test_12") is test_registry_decorator - def test_raise_registry_exceptions(self): + def test_register_none(self): with pytest.raises(TypeError): self.registry.register(name="not_valid")(None) + def test_find_multiple(self): with pytest.raises(TypeError): self.registry.find(name="this", multiple_kwargs="not_valid") + def test_find_not_found(self): # Test the not-found exception in find, raises key-error, returns None. assert self.registry.find(some_property="non_existant") is None - def test_remove(self): + def test_remove_object(self): # Test removing with just the object self.registry.remove("obj_1") assert self.registry.find(name="test_1") is None + # assert that every single reference is removed assert self.registry.find(name2="test_1") is None + def test_remove_keyword(self): # Test removing with just kwargs: self.registry.remove(name="test_2") assert self.registry.find(name="test_2") is None + # assert that every single reference is removed assert self.registry.find(name2="test_2") is None @@ -51,8 +50,7 @@ def test_remove(self): assert self.registry.find(name2="test_3") is None - self.registry.remove() - + def test_remove_empty_key(self): # Test removing the entire "test" key if it holds no items self.registry.register("obj_1", test="test_1") self.registry.remove(test="test_1") From 791c109bd553caa87a5c1d1491b2546ca5a840e8 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:46:09 -0700 Subject: [PATCH 090/118] Include encoders and decoders in armet/__init__ --- armet/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/armet/__init__.py b/armet/__init__.py index 513d9d4..5e02132 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,8 +1,8 @@ from ._version import __version__ -# from . import encoders, decoders +from . import encoders, decoders __all__ = [ __version__, - # encoders, - # decoders + encoders, + decoders ] From 94cd796b1deeebb5f566b7750dd3e283ceb43d27 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:47:08 -0700 Subject: [PATCH 091/118] Remove unused imports. --- armet/encoders/json.py | 3 +-- armet/encoders/url.py | 1 - armet/resources.py | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/armet/encoders/json.py b/armet/encoders/json.py index 8448b7d..4d4b266 100644 --- a/armet/encoders/json.py +++ b/armet/encoders/json.py @@ -6,8 +6,7 @@ from . import register -# @register(name="json", mime_types=JSONCodec.mime_types) -@register(name="json", mime_type="application/json") +@register(name="json", mime_type=JSONCodec.mime_types) def encode(data, encoding): # Ensure that the scalar data is wrapped in a list as # a valid JSON document be an object or a list. diff --git a/armet/encoders/url.py b/armet/encoders/url.py index c84b82b..62b6b56 100644 --- a/armet/encoders/url.py +++ b/armet/encoders/url.py @@ -1,4 +1,3 @@ -import json from itertools import chain, repeat from urllib.parse import urlencode diff --git a/armet/resources.py b/armet/resources.py index 6a399b8..187ec7a 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -1,4 +1,3 @@ -from collections import Iterable, Mapping, OrderedDict class CycleRegistry(dict): From 3e47e26730c38984dd0dee21eeae78012d6ed8cf Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 10:59:42 -0700 Subject: [PATCH 092/118] Update json encoding and decoding to use ujson. --- armet/decoders/json.py | 2 +- armet/encoders/json.py | 7 +++---- setup.py | 1 + tests/test_encoders.py | 21 +++++++-------------- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/armet/decoders/json.py b/armet/decoders/json.py index 4cf0d62..a339ed9 100644 --- a/armet/decoders/json.py +++ b/armet/decoders/json.py @@ -1,4 +1,4 @@ -import json +import ujson as json from ..codecs import JSONCodec from . import register diff --git a/armet/encoders/json.py b/armet/encoders/json.py index 4d4b266..e91a8a9 100644 --- a/armet/encoders/json.py +++ b/armet/encoders/json.py @@ -1,4 +1,4 @@ -import json +import ujson as json from collections import Iterable from ..codecs import JSONCodec @@ -14,9 +14,8 @@ def encode(data, encoding): if isinstance(data, str) or not isinstance(data, Iterable): data = [data] - # Separators are used here to assert that no uneccesary spaces are - # added to the json. - data = json.dumps(data, separators=(',', ':')) + # Dump the json data. + data = json.dumps(data) # TODO: Replace this with real json streaming return chunk(data.encode(encoding)) diff --git a/setup.py b/setup.py index 530606d..41ef6b6 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ url='http://github.com/armet/python-armet', packages=find_packages('.'), install_requires=[ + 'ujson', 'pytest', 'pytest-pep8', 'pytest-cov', diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 4bcc083..211e480 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -66,24 +66,17 @@ def test_encode_large_simple_list(self): assert json.loads(text) == data def test_encode_normal(self): - data = OrderedDict([ - ('foo', 5), - ('bar', None), - ('baz', ['a', 'b', 'c']), - ('bang', {'buzz': 'boop'})]) + data = { + 'foo': 5, + 'bar': None, + 'baz': ['a', 'b', 'c'], + 'bang': {'buzz': 'boop'}} - expected = ('{"foo":5,"bar":null,"baz":["a","b","c"],' - '"bang":{"buzz":"boop"}}') - - assert self.encode(data) == expected - - def test_encode_failure(self): - with pytest.raises(TypeError): - self.encode({'foo': range(10)}) + assert json.loads(self.encode(data)) == data @mark.bench('self.encoder', iterations=10000) -class TestFormDataEncoder(BaseEncoderTest): +class TestFormEncoder(BaseEncoderTest): def setup(self): self.encoder = encoders.find(name='form') From dd00a16b44fb67f590798c94f62bb8e4fae914fa Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 12:37:55 -0700 Subject: [PATCH 093/118] Mid test-writing --- armet/api.py | 5 ++- tests/test_api.py | 89 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/armet/api.py b/armet/api.py index 8be497e..cdcd798 100644 --- a/armet/api.py +++ b/armet/api.py @@ -57,8 +57,8 @@ def teardown(self): def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. - if handler.name is not None: - name = handler.name + if hasattr(handler, "name"): + name = handler.name if handler.name else None elif name is None: # Convert the name of the handler to dash-case name = utils.dasherize(handler.__name__) @@ -124,7 +124,6 @@ def find(self, path): # Attempt to find the resource through the initial path. context = {} - # import ipdb; ipdb.set_trace() segments = list(filter(None, path.split("/"))) while len(segments) > 2: # Pop the (name, slug) pair from the segments list. diff --git a/tests/test_api.py b/tests/test_api.py index 713d51d..2081b75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,42 +1,63 @@ from .base import RequestTest from armet.resources import Resource -from armet import encoders, decoders -from unittest import mock -from pytest import mark class TestAPI(RequestTest): - @mark.xfail - def test_route_resource(self): - # Create and register some resources. to test api routing. - retval = [{'foo': 'bar'}] + def test_register_name_with_resource_attribute(self): + # Create an example resource to register in the API + class FooResource: + name = "bar" - route_resource = mock.Mock(name='route_resource') - get_resource = mock.Mock(name='get_resource') - route_resource().route.return_value = retval - get_resource().read.return_value = retval + resource = FooResource - self.app.register(route_resource, name='route') - self.app.register(get_resource, name='read') + self.app.register(resource) - response = self.get('/read') - assert response.status_code == 200 - assert get_resource().read.called + assert self.app._registry['bar'] is resource - response = self.get('/route') - assert response.status_code == 200 - assert route_resource().read.called + def test_register_name_with_class_name(self): + class FooResource: + pass + + resource = FooResource + + self.app.register(resource) + + assert self.app._registry['foo'] is resource + + def test_register_name_with_kwargs(self): + class FooResource: + pass + + resource = FooResource + + self.app.register(resource, name="bar") + + assert self.app._registry['bar'] is resource - def test_internal_server_error_raises_500(self): - dead_resource = mock.Mock() - dead_resource().read.side_effect = ValueError('DeadResourceException!') + def test_40x_exception_debug(self): - self.app.register(dead_resource, name='test') + self.app.debug = True + + response = self.get('/unknown-resource') + + assert response.status_code == 404 + + def test_internal_server_error(self): + + self.app.debug = True + + class TestResource(Resource): + + def read(self): + raise Exception("This test raises an exception, and" + " prints to the console.") + + self.app.register(TestResource, name="test") response = self.get('/test') + assert response.status_code == 500 - assert dead_resource().read.called def test_redirect_get(self): response = self.get('/get/') @@ -69,3 +90,23 @@ def read(self): response = self.get('/test') assert response.status_code == 204 + + def test_route(self): + + self.app.debug = True + + class TestResource(Resource): + + first_name = "Test" + last_name = "Testerson" + + attributes = {'first_name', 'last_name'} + + def read(self): + return {"first_name": self.first_name, "last_name": self.last_name} + + self.app.register(TestResource, name="test") + + response = self.get('/test') + + assert response.status_code == 200 From d28a4721c116644fd3b7757883923b495785fd2d Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 12:55:11 -0700 Subject: [PATCH 094/118] Various fixes to test_api --- armet/api.py | 3 ++- armet/encoders/form.py | 3 ++- armet/encoders/json.py | 3 ++- armet/encoders/url.py | 4 ++-- armet/registry.py | 18 ++++++++++++++++++ tests/test_api.py | 11 +++++++++++ 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/armet/api.py b/armet/api.py index cdcd798..4cf0740 100644 --- a/armet/api.py +++ b/armet/api.py @@ -195,7 +195,8 @@ def encode(self, request, response, data): # TODO: We should be detecting the proper charset and using that # instead. response.response = encoder(data, 'utf-8') - response.headers['Content-Type'] = encoder.preferred_mime_type + response.headers['Content-Type'] = encoders._registry.rfind( + encoder, "preferred_mime_type", limit=1)[0] except (KeyError, TypeError) as ex: # Failed to find a matching encoder. diff --git a/armet/encoders/form.py b/armet/encoders/form.py index ec2790b..4ee169e 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -42,7 +42,8 @@ def segment_stream(cache, buf, segment_size=16*1024): yield value -@register(name="form", mime_type=FormCodec.mime_types) +@register(name="form", mime_type=FormCodec.mime_types, + preferred_mime_type=FormCodec.preferred_mime_type) def encode(data, encoding): """Expects to recieve a data structure of the following form: {name: value, name: [value1, value2]} diff --git a/armet/encoders/json.py b/armet/encoders/json.py index e91a8a9..7d737f4 100644 --- a/armet/encoders/json.py +++ b/armet/encoders/json.py @@ -6,7 +6,8 @@ from . import register -@register(name="json", mime_type=JSONCodec.mime_types) +@register(name="json", mime_type=JSONCodec.mime_types, + preferred_mime_type=JSONCodec.preferred_mime_type) def encode(data, encoding): # Ensure that the scalar data is wrapped in a list as # a valid JSON document be an object or a list. diff --git a/armet/encoders/url.py b/armet/encoders/url.py index 62b6b56..dbc1526 100644 --- a/armet/encoders/url.py +++ b/armet/encoders/url.py @@ -6,8 +6,8 @@ from . import register -# @register(name="url", mime_type=URLCodec.mime_types) -@register(name="url", mime_type=URLCodec.preferred_mime_type) +@register(name="url", mime_type=URLCodec.mime_types, + preferred_mime_type=URLCodec.preferred_mime_type) def encode(data, encoding): try: # Normalize the encode so that users pay invoke using either diff --git a/armet/registry.py b/armet/registry.py index de1a17a..5cff899 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -31,6 +31,24 @@ def callback(obj): # Just invoke the callback directly callback(obj) + def rfind(self, obj, key, limit=None): + """Lookup the values that `obj` was registered for `key`. + """ + # TODO: Use a reverse lookup cache to optimize the retreival here. + # The idea is that after we find the values we store it in + # a cache for later fast retrieval. This cache would need to + # be invalidated if the obj was re-registered with other things. + # This will never happen in practice so the cache will be great. + + values = [] + for value, item in self.map[key].items(): + if item == obj: + values.append(value) + if limit and len(values) >= limit: + break + + return values + def find(self, **kwargs): if len(kwargs) > 1: raise TypeError( diff --git a/tests/test_api.py b/tests/test_api.py index 2081b75..c381dba 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,9 +1,11 @@ from .base import RequestTest +from pytest import mark from armet.resources import Resource class TestAPI(RequestTest): + @mark.bench("self.app.register") def test_register_name_with_resource_attribute(self): # Create an example resource to register in the API class FooResource: @@ -15,6 +17,7 @@ class FooResource: assert self.app._registry['bar'] is resource + @mark.bench("self.app.register") def test_register_name_with_class_name(self): class FooResource: pass @@ -25,6 +28,7 @@ class FooResource: assert self.app._registry['foo'] is resource + @mark.bench("self.app.register") def test_register_name_with_kwargs(self): class FooResource: pass @@ -35,6 +39,7 @@ class FooResource: assert self.app._registry['bar'] is resource + @mark.bench("self.app.__call__") def test_40x_exception_debug(self): self.app.debug = True @@ -43,6 +48,7 @@ def test_40x_exception_debug(self): assert response.status_code == 404 + @mark.bench("self.app.__call__") def test_internal_server_error(self): self.app.debug = True @@ -59,11 +65,13 @@ def read(self): assert response.status_code == 500 + @mark.bench("self.app.__call__") def test_redirect_get(self): response = self.get('/get/') assert response.status_code == 301 assert response.headers["Location"].endswith("/get") + @mark.bench("self.app.__call__") def test_redirect_get_inverse(self): self.app.trailing_slash = True @@ -73,11 +81,13 @@ def test_redirect_get_inverse(self): response = self.get('/get') assert response.status_code == 301 + @mark.bench("self.app.__call__") def test_redirect_post(self): response = self.post('/post/') assert response.status_code == 307 assert response.headers["Location"].endswith("/post") + @mark.bench("self.app.__call__") def test_no_content(self): class TestResource(Resource): @@ -91,6 +101,7 @@ def read(self): assert response.status_code == 204 + @mark.bench("self.app.__call__") def test_route(self): self.app.debug = True From f49a4625cf526a154a191aa3f535e22e0f02ef49 Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 14:03:28 -0700 Subject: [PATCH 095/118] fixed an extremely obvious bug? --- armet/api.py | 5 ++++- tests/test_api.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/armet/api.py b/armet/api.py index 4cf0740..8e39f77 100644 --- a/armet/api.py +++ b/armet/api.py @@ -131,7 +131,10 @@ def find(self, path): slug = segments.pop(0) # Attempt to lookup the resource from the passed name. - resource_cls = self._registry[path] + resource_cls = self._registry.get(name) + + if resource_cls is None: + raise exceptions.NotFound() # Instantiate the resource. resource = resource_cls(slug=slug, context=context) diff --git a/tests/test_api.py b/tests/test_api.py index c381dba..0dd1ed8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -114,10 +114,11 @@ class TestResource(Resource): attributes = {'first_name', 'last_name'} def read(self): - return {"first_name": self.first_name, "last_name": self.last_name} + return [self.first_name, self.last_name] self.app.register(TestResource, name="test") + self.app.register(TestResource, name="test2") - response = self.get('/test') + response = self.get('/test/first_name/test2') assert response.status_code == 200 From 0e9e798e9d048f4a977715891e05541389bda177 Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 14:04:29 -0700 Subject: [PATCH 096/118] removed ipdb --- armet/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index 8e39f77..797ea2b 100644 --- a/armet/api.py +++ b/armet/api.py @@ -130,8 +130,8 @@ def find(self, path): name = segments.pop(0) slug = segments.pop(0) + resource_cls = self._registry.get(path) # Attempt to lookup the resource from the passed name. - resource_cls = self._registry.get(name) if resource_cls is None: raise exceptions.NotFound() From f05476248e84f045b8394de38347e7e77f3f2a5b Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Fri, 11 Jul 2014 14:09:23 -0700 Subject: [PATCH 097/118] Remove the limit idea; would interfere with the cache --- armet/registry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/armet/registry.py b/armet/registry.py index 5cff899..f34ab47 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -31,7 +31,7 @@ def callback(obj): # Just invoke the callback directly callback(obj) - def rfind(self, obj, key, limit=None): + def rfind(self, obj, key): """Lookup the values that `obj` was registered for `key`. """ # TODO: Use a reverse lookup cache to optimize the retreival here. @@ -44,8 +44,6 @@ def rfind(self, obj, key, limit=None): for value, item in self.map[key].items(): if item == obj: values.append(value) - if limit and len(values) >= limit: - break return values From 6d17fc7356ea9cec6d5cde6e80194de362166a31 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Fri, 11 Jul 2014 15:17:35 -0700 Subject: [PATCH 098/118] Allow mounting of separate apis on top of eachother. --- armet/api.py | 30 +++++++++++++++++++++++++++++- tests/test_api.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/armet/api.py b/armet/api.py index cdcd798..a075402 100644 --- a/armet/api.py +++ b/armet/api.py @@ -15,6 +15,13 @@ def __init__(self, trailing_slash=False, debug=False, expose=True, self._registry = {} self.name = name + # Dispatching registry used to delegate execution to other Api objects. + self._dispatcher = werkzeug.DispatcherMiddleware(self.wsgi) + + # Pull out the mount array from the dispatcher so that we can + # manipulate it in this class when users add sub-apis. + self._defers = self._dispatcher.mounts + # An attribute to disallow direct routing to a resource # default is true. self.expose = expose @@ -55,7 +62,23 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register(self, handler, *, expose=True, name=None): # noqa + def register_api(self, api, name=None): + if name is None: + name = getattr(api, 'name', None) + + if name is None: + name = utils.dasherize(api.__class__.__name__) + if name.endswith('-api'): + name = name[:-4] + + # Assert that the name begins with a slash in order to play nice + # with the werkzeug dispatcher middleware. + if not name[0] == '/': + name = '/' + name + + self._defers[name] = api + + def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. if hasattr(handler, "name"): name = handler.name if handler.name else None @@ -69,6 +92,11 @@ def register(self, handler, *, expose=True, name=None): # noqa self._registry[name] = handler def __call__(self, environ, start_response): + """WSGI Hook used to route execution to the correct Api object. + """ + return self._dispatcher(environ, start_response) + + def wsgi(self, environ, start_response): """Entry-point from the WSGI environment. When a request comes in from a "client" this is the first place diff --git a/tests/test_api.py b/tests/test_api.py index 2081b75..d31eec1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ from .base import RequestTest from armet.resources import Resource +from armet.api import Api class TestAPI(RequestTest): @@ -110,3 +111,30 @@ def read(self): response = self.get('/test') assert response.status_code == 200 + + def test_add_subapi(self): + + class PersonalApi(Api): + pass + + class SubResource(Resource): + attributes = {'success'} + + def read(self): + return [{'success': True}] + + # Assert that all methods of accessing an api via name work. + apis = [ + (Api(expose=True), {'name': 'test'}), + (Api(expose=True, name='new_test'), {}), + (PersonalApi(expose=True), {}), + ] + + for api, kwargs in apis: + api.register(SubResource, name='endpoint') + self.app.register_api(api, **kwargs) + + # import ipdb; ipdb.set_trace() + assert self.get('/test/endpoint').status_code == 200 + assert self.get('/new_test/endpoint').status_code == 200 + assert self.get('/personal/endpoint').status_code == 200 From 93e2ae646ece8650249f669e4363c72eca105dd5 Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 15:34:05 -0700 Subject: [PATCH 099/118] More tests --- armet/api.py | 45 +++++++++++++++++++----------------------- tests/test_api.py | 50 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/armet/api.py b/armet/api.py index 797ea2b..802c604 100644 --- a/armet/api.py +++ b/armet/api.py @@ -1,4 +1,4 @@ -from . import decoders, encoders +from . import decoders, encoders, registry from armet import utils from armet.http import exceptions, Request, Response import http @@ -10,9 +10,8 @@ class Api: def __init__(self, trailing_slash=False, debug=False, expose=True, name=None): - # TODO: Should this be that `Registry` thing we were talking about? - # That would give us the `remove` functionality easily - self._registry = {} + + self._registry = registry.Registry() self.name = name # An attribute to disallow direct routing to a resource @@ -28,6 +27,8 @@ def __init__(self, trailing_slash=False, debug=False, expose=True, self.trailing_slash = trailing_slash def redirect(self, request): + if request.path == "/": + raise exceptions.NotFound() # Format an absolute path to the URI (adjusted to be canonical). location = "%s://%s%s%s%s" % ( request.scheme, @@ -66,7 +67,7 @@ def register(self, handler, *, expose=True, name=None): # noqa name = name[:-9] # Insert the handler into the registry. - self._registry[name] = handler + self._registry.register(handler, name=name) def __call__(self, environ, start_response): """Entry-point from the WSGI environment. @@ -78,18 +79,18 @@ def __call__(self, environ, start_response): # Create the request wrapper around the environment. request = Request(environ) - # Test and decide if we need to redirect the client to - # the canonical representation of the given request path. - if self.trailing_slash ^ request.path.endswith('/'): - response = self.redirect(request) - return response(environ, start_response) - # Setup the request. - self.setup() + try: + # Test and decide if we need to redirect the client to + # the canonical representation of the given request path. + if self.trailing_slash ^ request.path.endswith('/'): + response = self.redirect(request) + return response(environ, start_response) + # Setup the request. + self.setup() - # Build an empty response object. - response = Response() + # Build an empty response object. + response = Response() - try: self.route(request, response) except exceptions.Base as ex: @@ -130,7 +131,7 @@ def find(self, path): name = segments.pop(0) slug = segments.pop(0) - resource_cls = self._registry.get(path) + resource_cls = self._registry.find(name=name) # Attempt to lookup the resource from the passed name. if resource_cls is None: @@ -148,9 +149,9 @@ def find(self, path): slug = segments[1] if len(segments) > 1 else None # Attempt to lookup the resource from the passed name. - if name not in self._registry: + resource_cls = self._registry.find(name=name) + if resource_cls is None: raise exceptions.NotFound() - resource_cls = self._registry[name] # Instantiate the resource. return resource_cls(slug=slug, context=context) @@ -209,24 +210,18 @@ def route(self, request, response): # TODO: We need a way to know the content-type here.. or the encoder # needs to handle pushing the content-type. - # Return an empty 404 if were accessed at "/" ( - # we don't handle this yet). - if request.path == "/": - raise exceptions.NotFound() - # Get the request data request_data = self.decode(request) # Instantiate the correct resource with resource = self.find(request.path) - # We are at the final segment in the URL; we need to route this # dependent on the HTTP/1.1 method. try: route = getattr(self, request.method.lower()) except AttributeError: - raise exceptions.MethodNotAllowed(['get']) + raise exceptions.MethodNotAllowed([request.method]) # Dispatch the request. response_data = route(resource, request_data) diff --git a/tests/test_api.py b/tests/test_api.py index 0dd1ed8..e835ba2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,7 +15,7 @@ class FooResource: self.app.register(resource) - assert self.app._registry['bar'] is resource + assert self.app._registry.find(name='bar') is resource @mark.bench("self.app.register") def test_register_name_with_class_name(self): @@ -26,7 +26,7 @@ class FooResource: self.app.register(resource) - assert self.app._registry['foo'] is resource + assert self.app._registry.find(name='foo') is resource @mark.bench("self.app.register") def test_register_name_with_kwargs(self): @@ -37,7 +37,7 @@ class FooResource: self.app.register(resource, name="bar") - assert self.app._registry['bar'] is resource + assert self.app._registry.find(name='bar') is resource @mark.bench("self.app.__call__") def test_40x_exception_debug(self): @@ -108,17 +108,49 @@ def test_route(self): class TestResource(Resource): - first_name = "Test" - last_name = "Testerson" + def prepare(self, item): + return item - attributes = {'first_name', 'last_name'} + def read(self): + return "data" + + class Test2Resource(Resource): + + def prepare(self, item): + return item def read(self): - return [self.first_name, self.last_name] + return "data2" self.app.register(TestResource, name="test") - self.app.register(TestResource, name="test2") - response = self.get('/test/first_name/test2') + self.app.register(Test2Resource, name="test2") + + response = self.get('/test/1/test2') + # Json serializer is broken? + assert response.data == b'["data2"]' assert response.status_code == 200 + + def test_multiname_route_with_invalid_resource(self): + """Test that we indeed get a 404 on a request with + 2+ "names", i.e /name/slug/name""" + + response = self.get('/name/slug/name') + + assert response.status_code == 404 + + def test_get_on_root(self): + response = self.get('/') + + assert response.status_code == 404 + + def test_method_not_allowed(self): + class FooResource(Resource): + pass + + self.app.register(FooResource) + + response = self.request('/foo', method="GARBAGE") + + assert response.status_code == 405 From f1584af8ce4a62d4293c8714850eb9e6acfb117a Mon Sep 17 00:00:00 2001 From: Pholey Date: Fri, 11 Jul 2014 16:13:47 -0700 Subject: [PATCH 100/118] added test for rfind, removed limit=1 --- armet/api.py | 4 ++-- tests/test_registry.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/armet/api.py b/armet/api.py index 3725f57..1162b1f 100644 --- a/armet/api.py +++ b/armet/api.py @@ -79,7 +79,7 @@ def register_api(self, api, name=None): self._defers[name] = api - def register(self, handler, *, expose=True, name=None): + def register(self, handler, *, expose=True, name=None): # noqa # Discern the name of the handler in order to register it. if hasattr(handler, "name"): name = handler.name if handler.name else None @@ -228,7 +228,7 @@ def encode(self, request, response, data): # instead. response.response = encoder(data, 'utf-8') response.headers['Content-Type'] = encoders._registry.rfind( - encoder, "preferred_mime_type", limit=1)[0] + encoder, "preferred_mime_type")[0] except (KeyError, TypeError) as ex: # Failed to find a matching encoder. diff --git a/tests/test_registry.py b/tests/test_registry.py index fab7cd5..ae62bfa 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -21,6 +21,11 @@ def test_register_none(self): with pytest.raises(TypeError): self.registry.register(name="not_valid")(None) + def test_rfind(self): + x = self.registry.rfind("obj_1", "name")[0] + + assert x == "test_1" + def test_find_multiple(self): with pytest.raises(TypeError): self.registry.find(name="this", multiple_kwargs="not_valid") From 1fdc555e1c541e35bee4e37aaf8e07e78d64da6b Mon Sep 17 00:00:00 2001 From: Pholey Date: Mon, 14 Jul 2014 10:51:11 -0700 Subject: [PATCH 101/118] added fallback registry --- armet/registry.py | 8 ++++++-- tests/test_registry.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/armet/registry.py b/armet/registry.py index f34ab47..6d60845 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -3,8 +3,12 @@ class Registry: - def __init__(self): + def __init__(self, registry=None): + """Generic registry for easy lookup, If the item is not found, + on find() And a "fallback" registry is provided, the item + requested will be searched through the fallback registry.""" self.map = defaultdict(dict) + self.fallback = registry def register(self, obj=None, **kwargs): @@ -69,7 +73,7 @@ def find(self, **kwargs): except KeyError: # If we don't find what they were looking for; return nothing. - return None + return self.fallback.find(**{key: value}) if self.fallback else None # noqa def remove(self, *args, **kwargs): # For each passed object we need to iterate through each nested diff --git a/tests/test_registry.py b/tests/test_registry.py index ae62bfa..b7f08dd 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -30,9 +30,13 @@ def test_find_multiple(self): with pytest.raises(TypeError): self.registry.find(name="this", multiple_kwargs="not_valid") - def test_find_not_found(self): + def test_find_from_fallback(self): # Test the not-found exception in find, raises key-error, returns None. - assert self.registry.find(some_property="non_existant") is None + fallback = Registry() + fallback.register("fallback_obj", name="fallback") + self.registry.fallback = fallback + + assert self.registry.find(name="fallback") == "fallback_obj" def test_remove_object(self): # Test removing with just the object From e18d36c522ef7f21ecc11bb5c2b1d626af7de93b Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Mon, 14 Jul 2014 15:27:12 -0700 Subject: [PATCH 102/118] Update after review. --- armet/api.py | 155 +++++++++++++++++++++++++-------------- armet/codecs.py | 6 +- armet/http/exceptions.py | 2 +- armet/registry.py | 54 +++++++------- armet/resources.py | 3 + tests/test_api.py | 22 +++--- tests/test_decoders.py | 6 +- tests/test_encoders.py | 6 +- tests/test_registry.py | 30 ++++---- 9 files changed, 166 insertions(+), 118 deletions(-) diff --git a/armet/api.py b/armet/api.py index 1162b1f..6ae2ba8 100644 --- a/armet/api.py +++ b/armet/api.py @@ -8,23 +8,26 @@ class Api: - def __init__(self, trailing_slash=False, debug=False, expose=True, - name=None): + def __init__(self, name=None, *, trailing_slash=False, debug=False): + # Resource registry that will contain any resources that could + # be exposed in this API context. + self._registry = registry.Registry(index={"name"}) - self._registry = registry.Registry() + # The name that this may take if it is registered in another API. self.name = name # Dispatching registry used to delegate execution to other Api objects. - self._dispatcher = werkzeug.DispatcherMiddleware(self.wsgi) + self._dispatcher = werkzeug.wsgi.DispatcherMiddleware(self.wsgi) + + # TODO: I don't like this but I liked the other thing less # Pull out the mount array from the dispatcher so that we can # manipulate it in this class when users add sub-apis. - self._defers = self._dispatcher.mounts + # self._defers = self._dispatcher.mounts - # An attribute to disallow direct routing to a resource - # default is true. - self.expose = expose - # Set if we're in debugging mode. + # Whether we're in debugging mode. + # This causes tracebacks to be sent with the response on + # a 5xx resposne. self.debug = debug # Trailing slash handling. @@ -34,8 +37,6 @@ def __init__(self, trailing_slash=False, debug=False, expose=True, self.trailing_slash = trailing_slash def redirect(self, request): - if request.path == "/": - raise exceptions.NotFound() # Format an absolute path to the URI (adjusted to be canonical). location = "%s://%s%s%s%s" % ( request.scheme, @@ -63,38 +64,31 @@ def teardown(self): """Called on request teardown in the context of this API. """ - def register_api(self, api, name=None): - if name is None: - name = getattr(api, 'name', None) - - if name is None: - name = utils.dasherize(api.__class__.__name__) - if name.endswith('-api'): - name = name[:-4] - - # Assert that the name begins with a slash in order to play nice - # with the werkzeug dispatcher middleware. - if not name[0] == '/': - name = '/' + name - - self._defers[name] = api - - def register(self, handler, *, expose=True, name=None): # noqa + def register(self, handler, *, expose=True, name=None): # Discern the name of the handler in order to register it. - if hasattr(handler, "name"): - name = handler.name if handler.name else None - elif name is None: - # Convert the name of the handler to dash-case - name = utils.dasherize(handler.__name__) - if name.endswith("-resource"): - name = name[:-9] + if name is None: + if getattr(handler, "name", None): + name = handler.name + else: + # Convert the name of the handler to dash-case. + if isinstance(handler, Api): + name = utils.dasherize(type(handler).__name__) + if name.endswith("-api"): + name = name[:-4] + + else: + name = utils.dasherize(handler.__name__) + if name.endswith("-resource"): + name = name[:-9] # Insert the handler into the registry. - self._registry.register(handler, name=name) + if isinstance(handler, Api): + self._dispatcher.mounts["/" + name] = handler + + else: + self._registry.register(handler, name=name, expose=expose) def __call__(self, environ, start_response): - """WSGI Hook used to route execution to the correct Api object. - """ return self._dispatcher(environ, start_response) def wsgi(self, environ, start_response): @@ -108,20 +102,27 @@ def wsgi(self, environ, start_response): request = Request(environ) try: + # TODO: We do not handle rendering an API index. We should. + if request.path == "/": + raise exceptions.NotFound() + # Test and decide if we need to redirect the client to # the canonical representation of the given request path. if self.trailing_slash ^ request.path.endswith('/'): response = self.redirect(request) return response(environ, start_response) + # Setup the request. self.setup() # Build an empty response object. response = Response() + # Dispatch the request. self.route(request, response) except exceptions.Base as ex: + # FIXME: Show the exception in the response not in stdout # If the exception raised was an error-like exception (4xx or 5xx) # then print the traceback as well. if self.debug and ex.code // 100 >= 4: @@ -132,10 +133,11 @@ def wsgi(self, environ, start_response): # objects can. response = ex - except Exception: + except Exception as ex: + print(ex) + response = exceptions.InternalServerError() if self.debug: traceback.print_exc() - response = exceptions.InternalServerError() # Teardown the request. # FIXME: This should happen directly before closing the connection @@ -146,25 +148,46 @@ def wsgi(self, environ, start_response): # response. return response(environ, start_response) - def find(self, path): - """Uses the request path to instantiate and return the correct - resource using the slug and context. - """ + def _find(self, path): + """Find and instantiate the right-most resource using path traversal. + Collects and forwards the resource context during traversal. + """ # Attempt to find the resource through the initial path. context = {} segments = list(filter(None, path.split("/"))) + count = 0 + last_resource_cls = None while len(segments) > 2: # Pop the (name, slug) pair from the segments list. name = segments.pop(0) slug = segments.pop(0) - resource_cls = self._registry.find(name=name) - # Attempt to lookup the resource from the passed name. + try: + # Attempt to lookup the resource from the passed name. + resource_cls, metadata = self._registry.find(name=name) - if resource_cls is None: + except KeyError: raise exceptions.NotFound() + if count == 0: + # If we are at the initial resource .. + # Check if this resource is allowed to be exposed. + if not metadata.get("expose"): + # This resource is not exposed at "/" + # NOTE: This resource -can- still be traversed to from + # another resource. + raise exceptions.NotFound() + + elif last_resource_cls is not None: + # Check if the last resource is allowed to traverse to this + # resource. + rels = getattr(last_resource_cls, "relationships", ()) + if name not in rels: + # This resource does not exist in context of the previous + # resource. + raise exceptions.NotFound() + # Instantiate the resource. resource = resource_cls(slug=slug, context=context) @@ -172,13 +195,32 @@ def find(self, path): # at the final segment in the url. context[name] = resource.read() + # Update the `last_resource_cls` (keeps track of the + # immediate-left resource) + last_resource_cls = resource_cls + + # Increment resource counter; keep track of how many resources + # have been traversed. + count += 1 + # Grab the final (name, slug?) pair from the list. name = segments[0] slug = segments[1] if len(segments) > 1 else None - # Attempt to lookup the resource from the passed name. - resource_cls = self._registry.find(name=name) - if resource_cls is None: + if last_resource_cls is not None: + # Check if the last resource is allowed to traverse to this + # resource. + rels = getattr(last_resource_cls, "relationships", ()) + if name not in rels: + # This resource does not exist in context of the previous + # resource. + raise exceptions.NotFound() + + try: + # Attempt to lookup the resource from the passed name. + resource_cls, _ = self._registry.find(name=name) + + except KeyError: raise exceptions.NotFound() # Instantiate the resource. @@ -200,7 +242,7 @@ def decode(self, request): # the mime_type (charset, etc.); think of how to deal # with it as media_range detection "works" with it but # mime_type would be faster. - decode = decoders.find(media_range=content_type) + decode, _ = decoders.find(media_range=content_type) # Decode the incoming request data. # TODO: We should likely be sending the proper charset @@ -221,28 +263,27 @@ def encode(self, request, response, data): media_range = "application/json" try: - encoder = encoders.find(media_range=media_range) + encoder, metadata = encoders.find(media_range=media_range) # Encode the data. # TODO: We should be detecting the proper charset and using that # instead. response.response = encoder(data, 'utf-8') - response.headers['Content-Type'] = encoders._registry.rfind( - encoder, "preferred_mime_type")[0] + response.headers['Content-Type'] = metadata["preferred_mime_type"] except (KeyError, TypeError) as ex: # Failed to find a matching encoder. raise exceptions.NotAcceptable() from ex def route(self, request, response): - # TODO: We need a way to know the content-type here.. or the encoder - # needs to handle pushing the content-type. + # Find and instantiate the right-most resource using path traversal. + resource = self._find(request.path) # Get the request data request_data = self.decode(request) # Instantiate the correct resource with - resource = self.find(request.path) + resource = self._find(request.path) # We are at the final segment in the URL; we need to route this # dependent on the HTTP/1.1 method. try: diff --git a/armet/codecs.py b/armet/codecs.py index e9877b4..fc2f7da 100644 --- a/armet/codecs.py +++ b/armet/codecs.py @@ -6,6 +6,10 @@ class CodecRegistry(Registry): """A registry used for registering and removing encoders and decoders. """ + def __init__(self, *args, **kwargs): + kwargs.setdefault("index", {"name", "mime_type"}) + super().__init__(*args, **kwargs) + def find_media_range(self, media_range): try: # best_match returns empty string on failure to find a match. @@ -18,7 +22,7 @@ def find_media_range(self, media_range): raise KeyError('Malformed media range.') # Push the resolved mime_type into the `find` method. - return self.find(mime_type=found) + return self.find(mime_type=found)[0] class URLCodec: diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index f9b5ee1..3171465 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -10,7 +10,7 @@ class Base(exceptions.HTTPException): def get_body(self, environ=None): - # There is no body that needs to be returned for api exceptions. + # There is no body that needs to be returned for api exceptions. return '' def get_headers(self, environ=None): diff --git a/armet/registry.py b/armet/registry.py index 6d60845..c6669dc 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -3,12 +3,17 @@ class Registry: - def __init__(self, registry=None): - """Generic registry for easy lookup, If the item is not found, - on find() And a "fallback" registry is provided, the item - requested will be searched through the fallback registry.""" + def __init__(self, fallback=None, index=None): + """Generic registry for easy lookup. + + If the item is not found, on find() And a "fallback" registry is + provided, the item requested will be searched through the fallback + registry. + """ self.map = defaultdict(dict) - self.fallback = registry + self.fallback = fallback + self.index = index + self.metadata = {} def register(self, obj=None, **kwargs): @@ -17,13 +22,21 @@ def callback(obj): raise TypeError("'%s' object cannot be registered" % type(obj).__name__) + metadata = {} for key, value in kwargs.items(): if isinstance(value, Iterable) and not isinstance(value, str): + metadata[key] = [] for val in value: - self.map[key][val] = obj + metadata[key].append(val) + if self.index is None or key in self.index: + self.map[key][val] = obj else: - self.map[key][value] = obj + metadata[key] = value + if self.index is None or key in self.index: + self.map[key][value] = obj + + self.metadata[obj] = metadata return obj @@ -35,22 +48,6 @@ def callback(obj): # Just invoke the callback directly callback(obj) - def rfind(self, obj, key): - """Lookup the values that `obj` was registered for `key`. - """ - # TODO: Use a reverse lookup cache to optimize the retreival here. - # The idea is that after we find the values we store it in - # a cache for later fast retrieval. This cache would need to - # be invalidated if the obj was re-registered with other things. - # This will never happen in practice so the cache will be great. - - values = [] - for value, item in self.map[key].items(): - if item == obj: - values.append(value) - - return values - def find(self, **kwargs): if len(kwargs) > 1: raise TypeError( @@ -69,11 +66,16 @@ def find(self, **kwargs): # Utilize the lookup method to attempt to find the object # by the passed value. - return lookup(value) + obj = lookup(value) + return obj, self.metadata[obj] except KeyError: - # If we don't find what they were looking for; return nothing. - return self.fallback.find(**{key: value}) if self.fallback else None # noqa + if self.fallback is not None: + # If we don't find what they were looking for; return nothing. + return self.fallback.find(**{key: value}) + + # Re-raise the key-error + raise def remove(self, *args, **kwargs): # For each passed object we need to iterate through each nested diff --git a/armet/resources.py b/armet/resources.py index 187ec7a..dd0a87b 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -41,6 +41,9 @@ class Resource: # from the `read` method. attributes = set() + # Set of resource names that can be traversed to from this resource. + relationships = set() + def __init__(self, slug=None, context=None): """ :param slug: Identifier that represents which item of the resource diff --git a/tests/test_api.py b/tests/test_api.py index 5c72613..3cdd3ac 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -16,7 +16,7 @@ class FooResource: self.app.register(resource) - assert self.app._registry.find(name='bar') is resource + assert self.app._registry.find(name='bar')[0] is resource @mark.bench("self.app.register") def test_register_name_with_class_name(self): @@ -27,7 +27,7 @@ class FooResource: self.app.register(resource) - assert self.app._registry.find(name='foo') is resource + assert self.app._registry.find(name='foo')[0] is resource @mark.bench("self.app.register") def test_register_name_with_kwargs(self): @@ -38,7 +38,7 @@ class FooResource: self.app.register(resource, name="bar") - assert self.app._registry.find(name='bar') is resource + assert self.app._registry.find(name='bar')[0] is resource @mark.bench("self.app.__call__") def test_40x_exception_debug(self): @@ -104,11 +104,12 @@ def read(self): @mark.bench("self.app.__call__") def test_route(self): - self.app.debug = True class TestResource(Resource): + relationships = {"test2"} + def prepare(self, item): return item @@ -124,15 +125,14 @@ def read(self): return "data2" self.app.register(TestResource, name="test") - self.app.register(Test2Resource, name="test2") response = self.get('/test/1/test2') - # Json serializer is broken? - assert response.data == b'["data2"]' assert response.status_code == 200 + assert response.data == b'["data2"]' + # @mark.xfail def test_add_subapi(self): class PersonalApi(Api): @@ -146,14 +146,14 @@ def read(self): # Assert that all methods of accessing an api via name work. apis = [ - (Api(expose=True), {'name': 'test'}), - (Api(expose=True, name='new_test'), {}), - (PersonalApi(expose=True), {}), + (Api(), {'name': 'test'}), + (Api(name='new_test'), {}), + (PersonalApi(), {}), ] for api, kwargs in apis: api.register(SubResource, name='endpoint') - self.app.register_api(api, **kwargs) + self.app.register(api, **kwargs) # import ipdb; ipdb.set_trace() assert self.get('/test/endpoint').status_code == 200 diff --git a/tests/test_decoders.py b/tests/test_decoders.py index c4fff7e..867580a 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -14,7 +14,7 @@ def test_decoders_api_methods(): class TestURLDecoder: def setup(self): - self.decode = decoders.find(name='url') + self.decode, _ = decoders.find(name='url') def test_decode_normal(self): data = 'foo=bar&bar=baz&fiz=buzz' @@ -35,7 +35,7 @@ def test_decode_object(self): class TestJSONDecoder: def setup(self): - self.decode = decoders.find(name='json') + self.decode, _ = decoders.find(name='json') def test_decode_normal(self): data = { @@ -55,7 +55,7 @@ def test_decode_failure(self): class TestFormDecoder: def setup(self): - self.decode = decoders.find(name='form') + self.decode, _ = decoders.find(name='form') def test_decode_normal(self): boundary = 'abc123' diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 211e480..565a186 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -26,7 +26,7 @@ def encode(self, data): class TestURLEncoder(BaseEncoderTest): def setup(self): - self.encoder = encoders.find(name='url') + self.encoder, _ = encoders.find(name='url') def test_encode_normal(self): data = OrderedDict(( @@ -47,7 +47,7 @@ def test_unable_to_encode(self): class TestJSONEncoder(BaseEncoderTest): def setup(self): - self.encoder = encoders.find(name='json') + self.encoder, _ = encoders.find(name='json') def test_encode_scalar(self): data = False @@ -79,7 +79,7 @@ def test_encode_normal(self): class TestFormEncoder(BaseEncoderTest): def setup(self): - self.encoder = encoders.find(name='form') + self.encoder, _ = encoders.find(name='form') def test_encode_normal(self): with mock.patch('armet.encoders.form.generate_boundary') as mocked: diff --git a/tests/test_registry.py b/tests/test_registry.py index b7f08dd..142a9e4 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -15,17 +15,12 @@ def test_register_decorator(self): def test_registry_decorator(): pass - assert self.registry.find(name="test_12") is test_registry_decorator + assert self.registry.find(name="test_12")[0] is test_registry_decorator def test_register_none(self): with pytest.raises(TypeError): self.registry.register(name="not_valid")(None) - def test_rfind(self): - x = self.registry.rfind("obj_1", "name")[0] - - assert x == "test_1" - def test_find_multiple(self): with pytest.raises(TypeError): self.registry.find(name="this", multiple_kwargs="not_valid") @@ -36,28 +31,31 @@ def test_find_from_fallback(self): fallback.register("fallback_obj", name="fallback") self.registry.fallback = fallback - assert self.registry.find(name="fallback") == "fallback_obj" + assert self.registry.find(name="fallback")[0] == "fallback_obj" def test_remove_object(self): # Test removing with just the object self.registry.remove("obj_1") - assert self.registry.find(name="test_1") is None + with pytest.raises(KeyError): + assert self.registry.find(name="test_1") - # assert that every single reference is removed - assert self.registry.find(name2="test_1") is None + with pytest.raises(KeyError): + # assert that every single reference is removed + assert self.registry.find(name2="test_1") def test_remove_keyword(self): # Test removing with just kwargs: self.registry.remove(name="test_2") + with pytest.raises(KeyError): + assert self.registry.find(name="test_2") - assert self.registry.find(name="test_2") is None - - # assert that every single reference is removed - assert self.registry.find(name2="test_2") is None + with pytest.raises(KeyError): + # assert that every single reference is removed + assert self.registry.find(name2="test_2") self.registry.remove(name="test_3", name2="test_3") - - assert self.registry.find(name2="test_3") is None + with pytest.raises(KeyError): + assert self.registry.find(name2="test_3") def test_remove_empty_key(self): # Test removing the entire "test" key if it holds no items From 7df13f3771c6e5c18e5fd066f9fe3e8f1006a3be Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 14 Jul 2014 18:56:00 -0700 Subject: [PATCH 103/118] Do a bunch of stuff (probably broken) --- setup.py | 52 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index 41ef6b6..bce1313 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,44 @@ #! /usr/bin/env python -from setuptools import setup, find_packages +from setuptools import setup, find_packages, Command from imp import load_source +import subprocess +class PyTest(Command): + # user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] + user_options = [] + + def initialize_options(self): + # super().initialize_options() + self.pytest_args = [ + '--verbose', + '--pep8', + '--cov', 'armet' + ] + + def finalize_options(self): + pass + + def run(self): + errno = subprocess.call(['py.test'] + self.pytest_args) + raise SystemExit(errno) + + +extras_require = { + 'test': [ + 'SQLAlchemy' + ] +} + +# Note: Should be ordered in reverse dependency order (why?) +tests_require = [ + 'pytest-pep8', + 'pytest-cov', + 'pytest-bench', + 'python-mimeparse', + 'pytest', +] + setup( name='armet', version=load_source('', 'armet/_version.py').__version__, @@ -12,9 +48,9 @@ classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', - 'Framework :: Bottle', - 'Framework :: Flask', - 'Framework :: Django', + # 'Framework :: Bottle', + # 'Framework :: Flask', + # 'Framework :: Django', # 'Framework :: CherryPy', # 'Framework :: Twisted', # 'Framework :: Pylons', @@ -33,11 +69,9 @@ packages=find_packages('.'), install_requires=[ 'ujson', - 'pytest', - 'pytest-pep8', - 'pytest-cov', - 'pytest-bench', - 'python-mimeparse', 'werkzeug' ], + cmdclass={'test': PyTest}, + extras_require=extras_require, + tests_require=tests_require, ) From a93155e50f6e24d438246ad8e4cdf3fe5725842f Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Mon, 14 Jul 2014 20:24:44 -0700 Subject: [PATCH 104/118] Add sqlalchemy resources. --- armet/resources/__init__.py | 7 ++ armet/resources/base.py | 45 +++++++++++ armet/resources/registry.py | 138 ++++++++++++++++++++++++++++++++++ armet/resources/sqlalchemy.py | 42 +++++++++++ tests/test_resources.py | 19 +++++ 5 files changed, 251 insertions(+) create mode 100644 armet/resources/__init__.py create mode 100644 armet/resources/base.py create mode 100644 armet/resources/registry.py create mode 100644 armet/resources/sqlalchemy.py create mode 100644 tests/test_resources.py diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py new file mode 100644 index 0000000..59a2b2d --- /dev/null +++ b/armet/resources/__init__.py @@ -0,0 +1,7 @@ +from .registry import CycleRegistry, prepares, cleans + +__all__ = [ + 'CycleRegistry', + 'prepares', + 'cleans' +] diff --git a/armet/resources/base.py b/armet/resources/base.py new file mode 100644 index 0000000..73b5097 --- /dev/null +++ b/armet/resources/base.py @@ -0,0 +1,45 @@ +from .registry import _prepare + + +class Resource: + + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + + def __init__(self, slug=None, context=None): + """ + :param slug: Identifier that represents which item of the resource + to return, if present. + :type context: str or None + + :param context: Context in which this resource is being called (eg. + a nested resource will receive the result of a `read` + from the parent resource in its context). + :type context: dict or None + """ + self.slug = slug + self.context = context or {} + + def prepare_item(self, item): + data = {} + for name in self.attributes: + try: + # Attempt to get the attribute from the item. + value = getattr(item, name) + + # Run the value through the preparation cycle. + value = _prepare(item, name, value) + + except AttributeError: + # Item does not have the attribute; just put `None` + # in the object. + value = None + continue + + data[name] = value + + return data + + def prepare(self, items): + return [self.prepare_item(item) for item in items] diff --git a/armet/resources/registry.py b/armet/resources/registry.py new file mode 100644 index 0000000..7a4a447 --- /dev/null +++ b/armet/resources/registry.py @@ -0,0 +1,138 @@ +from functools import partial + + +class CycleRegistry(dict): + + def __init__(self, fallback=None): + super().__init__() + + # A fallback dictionary to use if the key is not in this dictionary. + self._fallback = fallback + + def __missing__(self, key): + # If the key is a "type" we should attempt to iterate back through + # its MRO to resolve it based on inheritance. + if isinstance(key, type): + for cls in key.__mro__: + if cls in self: + # Cache this for the next lookup. + self[key] = self[cls] + + # Return the result. + return self[cls] + + # If we have a fallback dictionary, use that. + if self._fallback: + return self._fallback[key] + + # Raise a KeyError as we don't have this. + raise KeyError + + +# Registry of preparation functions (by name and type) +_prepares = CycleRegistry() + +# Registry of cleaning functions (by name and type) +_cleans = CycleRegistry() + + +def _cycle_attribute(registry, obj, name, value): + """Send an attribute through the preparation/cleaning cycle. + """ + try: + # Attempt to get a preparation function, by type. + cycler = registry[type(value)] + except KeyError: + try: + # Look up preparation function by name. + cycler = registry[name] + except KeyError: + # No preparation function. Just return the value. + return value + + # Prepare the value. + return cycler(obj, name, value) + + +_prepare = partial(_cycle_attribute, _prepares) +_clean = partial(_cycle_attribute, _cleans) + + +def prepares(*clauses): + """ + Registers a preparation function to be invoked for each clause in + the decorator. + + A preparation clause is invoked on each attribute after it is retrieved + from the item returned from the `read` function. + + A clause may be a string or a type. If it is a string then the clause + describes an attribute of a resource by name. If it is a type then the + clause would be applied to all attributes of that type (or are an instance + of that type). + + A preparation function may be "scoped" to a specific resource + by decorating a method (instead of a module function). + + :: + @prepares(datetime.datetime) + def prepare_datetime(item, key, value): + return value.isoformat() + + @prepares("id") + def prepare_id(item, key, value): + return str(value) + + class User(Resource): + + @prepares("id") + def prepare_id(self, item, key, value): + return value.hex + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _prepares[clause] = function + + # Return the original function. + return function + + return decorator + + +def cleans(*clauses): + """ + Registers a cleaning function to be invoked for each clause in + the decorator. + + A cleaning clause is invoked on each attribute after it is received + from the decoder and before it is sent to the `update` or `create` + functions. + + Otherwise the semantics are equivalent to the `prepares` decorator. + + :: + @cleans(datetime.datetime) + def clean_datetime(key, value): + from dateutil import parse + return parse(value) + + @cleans("id") + def clean_id(key, value): + return UUID(value) + + class User(Resource): + + @cleans("id") + def clean_id(self, key, value): + return UUID(value) + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _cleans[clause] = function + + # Return the original function. + return function + + return decorator diff --git a/armet/resources/sqlalchemy.py b/armet/resources/sqlalchemy.py new file mode 100644 index 0000000..9650c9f --- /dev/null +++ b/armet/resources/sqlalchemy.py @@ -0,0 +1,42 @@ +from .base import Resource + + +class SQLAlchemyResource(Resource): + + # The model class object that this sqlalchemy resource will use. + model = None + + # The session constructor used to build a session for this resource. + session = None + + def __new__(cls): + # This behaves as a hook to assert the set of attributes is ordered + # and begins with the slug attribute + + # TODO: this is executed every time an instance is constructed. + # move this to some kind of metaclass or metaclass hook. + attrs = set(cls.attributes) + attrs.discard(cls.slug_attribute) + cls.attributes = [cls.slug_attribute] + list(attrs) + + def read(self): + """Return a sqlalchemy query object of columns.""" + columns = (getattr(self.model, name) for name in self.attributes) + return self.session().query(*columns) + + def filter(self, saquery, armetquery): + """Filter hook! Used to filter the slug and any armet queries.""" + + # Filter by the slug. + if self.slug is not None: + saquery = saquery.filter_by(**{self.slug_attribute: self.slug}) + + # TODO: Armet query filtering. + return saquery + + def prepare(self, items): + # Turn models into dictionaries! + return [zip(self.attributes, x) for x in items] + + def prepare_item(self, item): + return zip(self.attributes, item) diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..b612868 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,19 @@ +import sqlalchemy.orm +from sqlalchemy.ext.declarative import declarative_base +import pytest + + +class SQLAlchemyTestBase: + + @pytest.fixture(autouse=True, scope="function") + def sqlalchemy_session(self, request): + self.engine = sqlalchemy.create_engine('sqlite:///:memory:') + self.Base = declarative_base() + self.Session = sqlalchemy.orm.sessionmaker(bind=self.engine) + + self.Base.metadata.create_all(self.engine) + self.session = self.Session() + + +class TestSqlalchemyResource(SQLAlchemyTestBase): + pass From 46272ab1a33a2d85f495e7a158e4f79f924c419f Mon Sep 17 00:00:00 2001 From: Pholey Date: Tue, 15 Jul 2014 09:15:15 -0700 Subject: [PATCH 105/118] Implemented base resource for metaclass utilization --- armet/resources.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/armet/resources.py b/armet/resources.py index dd0a87b..7dae6f0 100644 --- a/armet/resources.py +++ b/armet/resources.py @@ -35,15 +35,34 @@ def __missing__(self, key): _cleans = CycleRegistry() -class Resource: +class BaseResource(type): + + def __init__(self, name, bases, attrs): + super().__init__(name, bases, attrs) + + metadata = {} + + for base in bases: + metadata.update(getattr(base, "_meta", {})) + + meta = attrs.get("Meta") + if meta: + for name, value in vars(meta).items(): + if not name.startswith("_"): + metadata[name] = value + + +class Resource(metaclass=BaseResource): # Set of named attributes to faclitiate from the `item` returned # from the `read` method. attributes = set() - - # Set of resource names that can be traversed to from this resource. relationships = set() + class Meta: + # options = () + pass + def __init__(self, slug=None, context=None): """ :param slug: Identifier that represents which item of the resource @@ -59,7 +78,6 @@ def __init__(self, slug=None, context=None): self.context = context or {} def prepare_item(self, item): - # data = OrderedDict() data = {} for name in self.attributes: try: From 349d95112f2652ef50c65e85f3ed646dfa09add5 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 13:32:53 -0700 Subject: [PATCH 106/118] Fix some issues with sqlalchemy resource filtering and add tests for it. --- armet/__init__.py | 5 +-- armet/api.py | 5 ++- armet/resources/__init__.py | 6 +++- armet/resources/base.py | 4 +++ armet/resources/sqlalchemy.py | 13 ++++---- tests/__init__.py | 3 ++ tests/base.py | 10 +++++- tests/conftest.py | 3 ++ tests/db.py | 59 +++++++++++++++++++++++++++++++++++ tests/test_resources.py | 46 ++++++++++++++++++++------- 10 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/db.py diff --git a/armet/__init__.py b/armet/__init__.py index 5e02132..6017231 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,8 +1,9 @@ from ._version import __version__ -from . import encoders, decoders +from . import encoders, decoders, resources __all__ = [ __version__, encoders, - decoders + decoders, + resources, ] diff --git a/armet/api.py b/armet/api.py index 6ae2ba8..c7e0e11 100644 --- a/armet/api.py +++ b/armet/api.py @@ -306,7 +306,10 @@ def route(self, request, response): return def get(self, resource, data=None): - items = resource.read() + + # TODO: When armet queries are implemented, pass the query here. + # instead of None + items = resource.filter(resource.read(), None) if items is None: return diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py index 59a2b2d..a990f4c 100644 --- a/armet/resources/__init__.py +++ b/armet/resources/__init__.py @@ -1,7 +1,11 @@ from .registry import CycleRegistry, prepares, cleans +from .base import Resource +from .sqlalchemy import SQLAlchemyResource __all__ = [ + 'Resource', 'CycleRegistry', 'prepares', - 'cleans' + 'cleans', + 'SQLAlchemyResource', ] diff --git a/armet/resources/base.py b/armet/resources/base.py index 73b5097..fcc8b9e 100644 --- a/armet/resources/base.py +++ b/armet/resources/base.py @@ -41,5 +41,9 @@ def prepare_item(self, item): return data + def filter(self, saquery, armetquery): + # TODO: Armet filtering here. + return saquery + def prepare(self, items): return [self.prepare_item(item) for item in items] diff --git a/armet/resources/sqlalchemy.py b/armet/resources/sqlalchemy.py index 9650c9f..e067ab6 100644 --- a/armet/resources/sqlalchemy.py +++ b/armet/resources/sqlalchemy.py @@ -9,15 +9,16 @@ class SQLAlchemyResource(Resource): # The session constructor used to build a session for this resource. session = None - def __new__(cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # This behaves as a hook to assert the set of attributes is ordered # and begins with the slug attribute # TODO: this is executed every time an instance is constructed. # move this to some kind of metaclass or metaclass hook. - attrs = set(cls.attributes) - attrs.discard(cls.slug_attribute) - cls.attributes = [cls.slug_attribute] + list(attrs) + attrs = set(self.attributes) + attrs.discard(self.slug_attribute) + self.attributes = [self.slug_attribute] + list(attrs) def read(self): """Return a sqlalchemy query object of columns.""" @@ -36,7 +37,7 @@ def filter(self, saquery, armetquery): def prepare(self, items): # Turn models into dictionaries! - return [zip(self.attributes, x) for x in items] + return [self.prepare_item(x) for x in items] def prepare_item(self, item): - return zip(self.attributes, item) + return dict(zip(self.attributes, item)) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..027c9cc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,3 @@ + +# # Register fixtures. +# from .fixtures import * # flake8: noqa diff --git a/tests/base.py b/tests/base.py index 7c10944..18ea74f 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,14 @@ from armet import api import werkzeug.test from pytest import fixture +import json + + +class MyResponse(werkzeug.Response): + + @property + def json(self): + return json.loads(self.data.decode('utf-8')) class RequestTest: @@ -37,4 +45,4 @@ def delete(self, *args, **kwargs): def fixture_api(self, request): inst = request.instance inst.app = api.Api() - inst.client = werkzeug.test.Client(inst.app, werkzeug.Response) + inst.client = werkzeug.test.Client(inst.app, MyResponse) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..37d0ba7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +# Pytest fixture loading is really funky. +from .db import * # flake8: noqa + diff --git a/tests/db.py b/tests/db.py new file mode 100644 index 0000000..f7bee4d --- /dev/null +++ b/tests/db.py @@ -0,0 +1,59 @@ +import pytest +import sqlalchemy as sa + +# import ipdb; ipdb.set_trace() + +class DB: + + def __init__(self): + from sqlalchemy.ext.declarative import declarative_base + + self.engine = sa.create_engine('sqlite:///:memory:') + self.Base = declarative_base() + self.Session = sa.orm.sessionmaker(bind=self.engine) + + def create_all(self): + self.Base.metadata.create_all(self.engine) + + +@pytest.fixture(scope="function") +def db(): + return DB() + + +@pytest.fixture(scope="function") +def User(db): + class UserModel(db.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) + name = sa.Column(sa.Unicode, nullable=False, default='') + + db.create_all() + return UserModel + + +@pytest.fixture(scope="function") +def Box(db): + class BoxModel(db.Base): + __tablename__ = 'box' + id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) + user_id = sa.Column(sa.ForeignKey(User.id), nullable=False) + + db.create_all() + return BoxModel + + +@pytest.fixture(scope="function") +def users_fixture(db, User): + session = db.Session() + + # 10 users. + names = [ + 'joe', 'jerry', 'jon', 'jane', 'jim', + 'jack', 'jeff', 'jay', 'james', 'johansen'] + + for name in names: + session.add(User(name=name)) + + session.commit() + session.close() diff --git a/tests/test_resources.py b/tests/test_resources.py index b612868..32196e3 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,19 +1,41 @@ -import sqlalchemy.orm -from sqlalchemy.ext.declarative import declarative_base import pytest +import armet +from .base import RequestTest -class SQLAlchemyTestBase: +@pytest.mark.usefixtures('users_fixture', 'request') +class TestSqlalchemyResource(RequestTest): - @pytest.fixture(autouse=True, scope="function") - def sqlalchemy_session(self, request): - self.engine = sqlalchemy.create_engine('sqlite:///:memory:') - self.Base = declarative_base() - self.Session = sqlalchemy.orm.sessionmaker(bind=self.engine) + @pytest.fixture(autouse=True) + def setup_resource(self, User, db): + class UserResource(armet.resources.SQLAlchemyResource): + model = User + session = db.Session + slug_attribute = 'id' + attributes = {'id', 'name'} - self.Base.metadata.create_all(self.engine) - self.session = self.Session() + self.app.register(UserResource, name='test') + def test_read_all_success(self): + response = self.get('/test') -class TestSqlalchemyResource(SQLAlchemyTestBase): - pass + assert response.status_code == 200 + + def test_read_all_return_all_data(self): + response = self.get('/test') + + assert response.status_code == 200 + + assert len(response.json) == 10 + + def test_read_item_uses_slug_attribute(self): + slugs = range(1, 11) + + responses = [self.get('/test/{}'.format(x)) for x in slugs] + + self.app.debug = True + + for response, slug in zip(responses, slugs): + assert response.status_code == 200 + + assert response.json['id'] == slug From a9db817331808791b5799ab851e22d07983c5def Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 13:36:07 -0700 Subject: [PATCH 107/118] Merge the resource metaclass stuff --- armet/resources.py | 193 ------------------------------------ armet/resources/__init__.py | 3 +- armet/resources/base.py | 24 ++++- 3 files changed, 25 insertions(+), 195 deletions(-) delete mode 100644 armet/resources.py diff --git a/armet/resources.py b/armet/resources.py deleted file mode 100644 index 7dae6f0..0000000 --- a/armet/resources.py +++ /dev/null @@ -1,193 +0,0 @@ - - -class CycleRegistry(dict): - - def __init__(self, fallback=None): - super().__init__() - - # A fallback dictionary to use if the key is not in this dictionary. - self._fallback = fallback - - def __missing__(self, key): - # If the key is a "type" we should attempt to iterate back through - # its MRO to resolve it based on inheritance. - if isinstance(key, type): - for cls in key.__mro__: - if cls in self: - # Cache this for the next lookup. - self[key] = self[cls] - - # Return the result. - return self[cls] - - # If we have a fallback dictionary, use that. - if self._fallback: - return self._fallback[key] - - # Raise a KeyError as we don't have this. - raise KeyError - - -# Registry of preparation functions (by name and type) -_prepares = CycleRegistry() - -# Registry of cleaning functions (by name and type) -_cleans = CycleRegistry() - - -class BaseResource(type): - - def __init__(self, name, bases, attrs): - super().__init__(name, bases, attrs) - - metadata = {} - - for base in bases: - metadata.update(getattr(base, "_meta", {})) - - meta = attrs.get("Meta") - if meta: - for name, value in vars(meta).items(): - if not name.startswith("_"): - metadata[name] = value - - -class Resource(metaclass=BaseResource): - - # Set of named attributes to faclitiate from the `item` returned - # from the `read` method. - attributes = set() - relationships = set() - - class Meta: - # options = () - pass - - def __init__(self, slug=None, context=None): - """ - :param slug: Identifier that represents which item of the resource - to return, if present. - :type context: str or None - - :param context: Context in which this resource is being called (eg. - a nested resource will receive the result of a `read` - from the parent resource in its context). - :type context: dict or None - """ - self.slug = slug - self.context = context or {} - - def prepare_item(self, item): - data = {} - for name in self.attributes: - try: - # Attempt to get the attribute from the item. - value = getattr(item, name) - - except AttributeError: - # Item does not have the attribute; just put `None` - # in the object. - data[name] = None - continue - - try: - # Attempt to get a preparation function, by type. - prepare = _prepares[type(value)] - - except KeyError: - try: - # Attempt to get a preparation function, by name. - prepare = _prepares[name] - - except KeyError: - # No preparation function; continue. - data[name] = value - continue - - # Prepare and push the attribute in the object. - data[name] = prepare(item, name, value) - - return data - - def prepare(self, items): - return [self.prepare_item(item) for item in items] - - -def prepares(*clauses): - """ - Registers a preparation function to be invoked for each clause in - the decorator. - - A preparation clause is invoked on each attribute after it is retrieved - from the item returned from the `read` function. - - A clause may be a string or a type. If it is a string then the clause - describes an attribute of a resource by name. If it is a type then the - clause would be applied to all attributes of that type (or are an instance - of that type). - - A preparation function may be "scoped" to a specific resource - by decorating a method (instead of a module function). - - :: - @prepares(datetime.datetime) - def prepare_datetime(item, key, value): - return value.isoformat() - - @prepares("id") - def prepare_id(item, key, value): - return str(value) - - class User(Resource): - - @prepares("id") - def prepare_id(self, item, key, value): - return value.hex - """ - def decorator(function): - # Register this preparation function for each passed clause. - for clause in clauses: - _prepares[clause] = function - - # Return the original function. - return function - - return decorator - - -def cleans(*clauses): - """ - Registers a cleaning function to be invoked for each clause in - the decorator. - - A cleaning clause is invoked on each attribute after it is received - from the decoder and before it is sent to the `update` or `create` - functions. - - Otherwise the semantics are equivalent to the `prepares` decorator. - - :: - @cleans(datetime.datetime) - def clean_datetime(key, value): - from dateutil import parse - return parse(value) - - @cleans("id") - def clean_id(key, value): - return UUID(value) - - class User(Resource): - - @cleans("id") - def clean_id(self, key, value): - return UUID(value) - """ - def decorator(function): - # Register this preparation function for each passed clause. - for clause in clauses: - _cleans[clause] = function - - # Return the original function. - return function - - return decorator diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py index a990f4c..aeb6b64 100644 --- a/armet/resources/__init__.py +++ b/armet/resources/__init__.py @@ -1,5 +1,5 @@ from .registry import CycleRegistry, prepares, cleans -from .base import Resource +from .base import Resource, ResourceMeta from .sqlalchemy import SQLAlchemyResource __all__ = [ @@ -8,4 +8,5 @@ 'prepares', 'cleans', 'SQLAlchemyResource', + 'ResourceMeta', ] diff --git a/armet/resources/base.py b/armet/resources/base.py index fcc8b9e..79be3a5 100644 --- a/armet/resources/base.py +++ b/armet/resources/base.py @@ -1,11 +1,33 @@ from .registry import _prepare -class Resource: +class ResourceMeta(type): + + def __init__(self, name, bases, attrs): + super().__init__(name, bases, attrs) + + metadata = {} + + for base in bases: + metadata.update(getattr(base, "_meta", {})) + + meta = attrs.get("Meta") + if meta: + for name, value in vars(meta).items(): + if not name.startswith("_"): + metadata[name] = value + + +class Resource(metaclass=ResourceMeta): # Set of named attributes to faclitiate from the `item` returned # from the `read` method. attributes = set() + relationships = set() + + class Meta: + # options = () + pass def __init__(self, slug=None, context=None): """ From 6e891a8edda2270198129b12370c53abe7cb0a04 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 16:15:55 -0700 Subject: [PATCH 108/118] Refactor tests to use fixture dependency injection --- armet/api.py | 49 ++++++++++------ armet/encoders/form.py | 3 + armet/http/exceptions.py | 15 +++-- armet/utils.py | 19 +++++++ tests/base.py | 48 ---------------- tests/conftest.py | 80 +++++++++++++++++++++++++- tests/db.py | 59 -------------------- tests/test_api.py | 117 +++++++++++++++++++-------------------- tests/test_resources.py | 50 ++++++++++++----- 9 files changed, 236 insertions(+), 204 deletions(-) delete mode 100644 tests/base.py delete mode 100644 tests/db.py diff --git a/armet/api.py b/armet/api.py index c7e0e11..5c012a3 100644 --- a/armet/api.py +++ b/armet/api.py @@ -115,11 +115,9 @@ def wsgi(self, environ, start_response): # Setup the request. self.setup() - # Build an empty response object. - response = Response() - - # Dispatch the request. - self.route(request, response) + # Dispatch the request with an empty response object. Return + # the response in the event a new one was generated during routing. + response = self.route(request, Response()) except exceptions.Base as ex: # FIXME: Show the exception in the response not in stdout @@ -128,13 +126,10 @@ def wsgi(self, environ, start_response): if self.debug and ex.code // 100 >= 4: traceback.print_exc() - # An HTTP/1.1 understood exception was raised from somewhere - # These exceptions can be invoked the same way that response - # objects can. + # Exceptions also double as response objects. Just use that. response = ex except Exception as ex: - print(ex) response = exceptions.InternalServerError() if self.debug: traceback.print_exc() @@ -292,18 +287,38 @@ def route(self, request, response): except AttributeError: raise exceptions.MethodNotAllowed([request.method]) - # Dispatch the request. - response_data = route(resource, request_data) - if response_data is None: - response.status_code = 204 - return + try: + + # Dispatch the request. + response_data = route(resource, request_data) + if response_data is None: + response.status_code = 204 + return response + + except exceptions.Base as ex: + # An HTTP/1.1 understood exception was raised from somewhere + # These exceptions can be invoked the same way that response + # objects can. + + # If these exceptions have a message associated with them, + # then continue as if that was the response data. Otherwise, + # just continue tossing it up the stack. + response_data = ex.message + if not response_data: + raise + + # Generate a new response based on the exception tossed. + # This response will still go through the encoding cycle to encode + # any error messages. + response = ex.get_response() + else: + # Return a successful response. + response.status_code = 200 # Write the response data into the response object self.encode(request, response, response_data) - # Return a successful response. - response.status_code = 200 - return + return response def get(self, resource, data=None): diff --git a/armet/encoders/form.py b/armet/encoders/form.py index 4ee169e..5b15407 100644 --- a/armet/encoders/form.py +++ b/armet/encoders/form.py @@ -7,6 +7,9 @@ # FIXME: Should use the make_boundary function from the stdlib +# COUNTER: make_boundary is no longer part of the standard library. It was +# removed in python3 The implementation is still there, but it is private +# and therefore we should not be using it. def generate_boundary(): """http://xkcd.com/221/""" return uuid.uuid4().hex diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index 3171465..7df11f6 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -1,6 +1,6 @@ from werkzeug import exceptions from http import client -from collections import OrderedDict +from armet import utils # Mirrors to werkzeug exceptions in the event we need to overload # these ourselves. @@ -9,17 +9,22 @@ class Base(exceptions.HTTPException): + @property + def message(self): + """Convenient accessor for any message provided for this exception.""" + return self.description + def get_body(self, environ=None): - # There is no body that needs to be returned for api exceptions. + # Api exceptions have an optional content parameter. return '' def get_headers(self, environ=None): # Because we're no longer returning html, our content type is no # longer text/html # return [('Content-Type', 'text/plain')] - headers = OrderedDict(super().get_headers(environ)) - headers['Content-Type'] = 'text/plain' - return headers.items() + headers = super().get_headers(environ) + content = [('Content-Type', 'text/plain')] + return utils.merge_headers(content, headers) # The following exceptions are already implemented in werkzeug Wrap them diff --git a/armet/utils.py b/armet/utils.py index 81f8971..702a62d 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -1,3 +1,6 @@ +from collections import OrderedDict +import itertools + def dasherize(text): result = '' @@ -27,3 +30,19 @@ def chunk(data, chunk_size=16*1024): if not buf: break yield buf + + +def merge_headers(*headers): + """Merge lists of headers into a new list of headers. + + Headers are merged prioritizing attributes from the leftmost list of + headers. That is to say that the first list keeps all of its headers. + """ + + exist = set() + new = [] + for entry in itertools.chain.from_iterable(headers): + if entry[0] not in exist: + exist.add(entry[0]) + new.append(entry) + return new diff --git a/tests/base.py b/tests/base.py deleted file mode 100644 index 18ea74f..0000000 --- a/tests/base.py +++ /dev/null @@ -1,48 +0,0 @@ -from armet import api -import werkzeug.test -from pytest import fixture -import json - - -class MyResponse(werkzeug.Response): - - @property - def json(self): - return json.loads(self.data.decode('utf-8')) - - -class RequestTest: - - def request(self, path, **kwargs): - # method and path are required arguments for sanity checking - # purposes. - - # By default, use json. - headers = kwargs.get('headers', {}) - headers.setdefault('Content-Type', 'application/json') - headers.setdefault('Accept', 'application/json') - - environ = werkzeug.test.create_environ( - path=path, - **kwargs) - - return self.client.open(environ) - - # Helper functions! - def get(self, *args, **kwargs): - return self.request(*args, method='GET', **kwargs) - - def post(self, *args, **kwargs): - return self.request(*args, method='POST', **kwargs) - - def put(self, *args, **kwargs): - return self.request(*args, method='PUT', **kwargs) - - def delete(self, *args, **kwargs): - return self.request(*args, method='DELETE', **kwargs) - - @fixture(autouse=True, scope="function") - def fixture_api(self, request): - inst = request.instance - inst.app = api.Api() - inst.client = werkzeug.test.Client(inst.app, MyResponse) diff --git a/tests/conftest.py b/tests/conftest.py index 37d0ba7..4d12072 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,79 @@ -# Pytest fixture loading is really funky. -from .db import * # flake8: noqa +import pytest +import sqlalchemy as sa +from armet import api +import werkzeug.test +import json + +class DB: + + def __init__(self): + from sqlalchemy.ext.declarative import declarative_base + + self.engine = sa.create_engine('sqlite:///:memory:') + self.Base = declarative_base() + self.Session = sa.orm.sessionmaker(bind=self.engine) + + def create_all(self): + self.Base.metadata.create_all(self.engine) + + +@pytest.fixture(scope="function") +def db(): + return DB() + + +@pytest.yield_fixture(scope='function') +def session(request, db): + """Adds a sqlalchemy session object to the test.""" + request.instance.session = db.Session() + + yield request.instance.session + + request.instance.session.close() + + +class MyResponse(werkzeug.Response): + + @property + def json(self): + return json.loads(self.data.decode('utf-8')) + + +class HTTP: + def __init__(self): + self.app = api.Api() + self.client = werkzeug.test.Client(self.app, MyResponse) + + def request(self, path, **kwargs): + # method and path are required arguments for sanity checking + # purposes. + + # By default, use json. + headers = kwargs.get('headers', {}) + headers.setdefault('Content-Type', 'application/json') + headers.setdefault('Accept', 'application/json') + + environ = werkzeug.test.create_environ( + path=path, + **kwargs) + + return self.client.open(environ) + + # Helper functions! + def get(self, *args, **kwargs): + return self.request(*args, method='GET', **kwargs) + + def post(self, *args, **kwargs): + return self.request(*args, method='POST', **kwargs) + + def put(self, *args, **kwargs): + return self.request(*args, method='PUT', **kwargs) + + def delete(self, *args, **kwargs): + return self.request(*args, method='DELETE', **kwargs) + + +@pytest.fixture(scope='function') +def http(): + return HTTP() diff --git a/tests/db.py b/tests/db.py deleted file mode 100644 index f7bee4d..0000000 --- a/tests/db.py +++ /dev/null @@ -1,59 +0,0 @@ -import pytest -import sqlalchemy as sa - -# import ipdb; ipdb.set_trace() - -class DB: - - def __init__(self): - from sqlalchemy.ext.declarative import declarative_base - - self.engine = sa.create_engine('sqlite:///:memory:') - self.Base = declarative_base() - self.Session = sa.orm.sessionmaker(bind=self.engine) - - def create_all(self): - self.Base.metadata.create_all(self.engine) - - -@pytest.fixture(scope="function") -def db(): - return DB() - - -@pytest.fixture(scope="function") -def User(db): - class UserModel(db.Base): - __tablename__ = 'user' - id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) - name = sa.Column(sa.Unicode, nullable=False, default='') - - db.create_all() - return UserModel - - -@pytest.fixture(scope="function") -def Box(db): - class BoxModel(db.Base): - __tablename__ = 'box' - id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) - user_id = sa.Column(sa.ForeignKey(User.id), nullable=False) - - db.create_all() - return BoxModel - - -@pytest.fixture(scope="function") -def users_fixture(db, User): - session = db.Session() - - # 10 users. - names = [ - 'joe', 'jerry', 'jon', 'jane', 'jim', - 'jack', 'jeff', 'jay', 'james', 'johansen'] - - for name in names: - session.add(User(name=name)) - - session.commit() - session.close() diff --git a/tests/test_api.py b/tests/test_api.py index 3cdd3ac..2ee1a2e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,58 +1,54 @@ -from .base import RequestTest from pytest import mark +from unittest import mock from armet.resources import Resource from armet.api import Api -class TestAPI(RequestTest): +class TestAPI: - @mark.bench("self.app.register") - def test_register_name_with_resource_attribute(self): + @mark.bench("http.app.register") + def test_register_name_with_resource_attribute(self, http): # Create an example resource to register in the API class FooResource: name = "bar" resource = FooResource - self.app.register(resource) + http.app.register(resource) - assert self.app._registry.find(name='bar')[0] is resource + assert http.app._registry.find(name='bar')[0] is resource - @mark.bench("self.app.register") - def test_register_name_with_class_name(self): + @mark.bench("http.app.register") + def test_register_name_with_class_name(self, http): class FooResource: pass resource = FooResource - self.app.register(resource) + http.app.register(resource) - assert self.app._registry.find(name='foo')[0] is resource + assert http.app._registry.find(name='foo')[0] is resource - @mark.bench("self.app.register") - def test_register_name_with_kwargs(self): + @mark.bench("http.app.register") + def test_register_name_with_kwargs(self, http): class FooResource: pass resource = FooResource - self.app.register(resource, name="bar") + http.app.register(resource, name="bar") - assert self.app._registry.find(name='bar')[0] is resource + assert http.app._registry.find(name='bar')[0] is resource - @mark.bench("self.app.__call__") - def test_40x_exception_debug(self): + @mark.bench("http.app.__call__") + def test_40x_exception_debug(self, http): - self.app.debug = True - - response = self.get('/unknown-resource') + response = http.get('/unknown-resource') assert response.status_code == 404 - @mark.bench("self.app.__call__") - def test_internal_server_error(self): - - self.app.debug = True + @mark.bench("http.app.__call__") + def test_internal_server_error(self, http): class TestResource(Resource): @@ -60,51 +56,54 @@ def read(self): raise Exception("This test raises an exception, and" " prints to the console.") - self.app.register(TestResource, name="test") + http.app.register(TestResource, name="test") + + http.app.debug = True - response = self.get('/test') + with mock.patch('traceback.print_exc') as mocked: + response = http.get('/test') + assert mocked.called assert response.status_code == 500 - @mark.bench("self.app.__call__") - def test_redirect_get(self): - response = self.get('/get/') + @mark.bench("http.app.__call__") + def test_redirect_get(self, http): + response = http.get('/get/') assert response.status_code == 301 assert response.headers["Location"].endswith("/get") - @mark.bench("self.app.__call__") - def test_redirect_get_inverse(self): - self.app.trailing_slash = True + @mark.bench("http.app.__call__") + def test_redirect_get_inverse(self, http): + http.app.trailing_slash = True - response = self.get('/get/') + response = http.get('/get/') assert response.status_code == 404 - response = self.get('/get') + response = http.get('/get') assert response.status_code == 301 - @mark.bench("self.app.__call__") - def test_redirect_post(self): - response = self.post('/post/') + @mark.bench("http.app.__call__") + def test_redirect_post(self, http): + response = http.post('/post/') assert response.status_code == 307 assert response.headers["Location"].endswith("/post") - @mark.bench("self.app.__call__") - def test_no_content(self): + @mark.bench("http.app.__call__") + def test_no_content(self, http): class TestResource(Resource): def read(self): return None - self.app.register(TestResource, name="test") + http.app.register(TestResource, name="test") - response = self.get('/test') + response = http.get('/test') assert response.status_code == 204 - @mark.bench("self.app.__call__") - def test_route(self): - self.app.debug = True + @mark.bench("http.app.__call__") + def test_route(self, http): class TestResource(Resource): @@ -124,16 +123,15 @@ def prepare(self, item): def read(self): return "data2" - self.app.register(TestResource, name="test") - self.app.register(Test2Resource, name="test2") + http.app.register(TestResource, name="test") + http.app.register(Test2Resource, name="test2") - response = self.get('/test/1/test2') + response = http.get('/test/1/test2') assert response.status_code == 200 assert response.data == b'["data2"]' - # @mark.xfail - def test_add_subapi(self): + def test_add_subapi(self, http): class PersonalApi(Api): pass @@ -153,32 +151,31 @@ def read(self): for api, kwargs in apis: api.register(SubResource, name='endpoint') - self.app.register(api, **kwargs) + http.app.register(api, **kwargs) - # import ipdb; ipdb.set_trace() - assert self.get('/test/endpoint').status_code == 200 - assert self.get('/new_test/endpoint').status_code == 200 - assert self.get('/personal/endpoint').status_code == 200 + assert http.get('/test/endpoint').status_code == 200 + assert http.get('/new_test/endpoint').status_code == 200 + assert http.get('/personal/endpoint').status_code == 200 - def test_multiname_route_with_invalid_resource(self): + def test_multiname_route_with_invalid_resource(self, http): """Test that we indeed get a 404 on a request with 2+ "names", i.e /name/slug/name""" - response = self.get('/name/slug/name') + response = http.get('/name/slug/name') assert response.status_code == 404 - def test_get_on_root(self): - response = self.get('/') + def test_get_on_root(self, http): + response = http.get('/') assert response.status_code == 404 - def test_method_not_allowed(self): + def test_method_not_allowed(self, http): class FooResource(Resource): pass - self.app.register(FooResource) + http.app.register(FooResource) - response = self.request('/foo', method="GARBAGE") + response = http.request('/foo', method="GARBAGE") assert response.status_code == 405 diff --git a/tests/test_resources.py b/tests/test_resources.py index 32196e3..2d9cfb3 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,39 +1,63 @@ import pytest import armet -from .base import RequestTest +import sqlalchemy as sa -@pytest.mark.usefixtures('users_fixture', 'request') -class TestSqlalchemyResource(RequestTest): +@pytest.fixture(autouse=True, scope="function") +def User(db): + class UserModel(db.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) + name = sa.Column(sa.Unicode, nullable=False, default='') + + db.create_all() + return UserModel + + +@pytest.fixture(autouse=True, scope="function") +def users_fixture(request, db, User, session): + # 10 users. + request.instance.names = names = [ + 'joe', 'jerry', 'jon', 'jane', 'jim', + 'jack', 'jeff', 'jay', 'james', 'johansen'] + + for name in names: + session.add(User(name=name)) + + session.commit() + + +@pytest.mark.usefixtures('users_fixture') +class TestSqlalchemyResource: @pytest.fixture(autouse=True) - def setup_resource(self, User, db): + def setup_resource(self, User, db, http): class UserResource(armet.resources.SQLAlchemyResource): model = User session = db.Session slug_attribute = 'id' attributes = {'id', 'name'} - self.app.register(UserResource, name='test') + http.app.register(UserResource, name='test') - def test_read_all_success(self): - response = self.get('/test') + def test_read_all_success(self, http): + response = http.get('/test') assert response.status_code == 200 - def test_read_all_return_all_data(self): - response = self.get('/test') + def test_read_all_return_all_data(self, http): + response = http.get('/test') assert response.status_code == 200 assert len(response.json) == 10 - def test_read_item_uses_slug_attribute(self): - slugs = range(1, 11) + def test_read_item_uses_slug_attribute(self, http): + slugs = range(1, len(self.names) + 1) - responses = [self.get('/test/{}'.format(x)) for x in slugs] + responses = [http.get('/test/{}'.format(x)) for x in slugs] - self.app.debug = True + http.app.debug = True for response, slug in zip(responses, slugs): assert response.status_code == 200 From fab7673b11684b7f90b3a1f8dbf6ff6a5f9b2500 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 16:28:46 -0700 Subject: [PATCH 109/118] Add tests for exception throwing content. --- tests/test_exceptions.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_exceptions.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..c4c57d0 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,30 @@ +from armet.http import exceptions +from armet.resources import Resource as BaseResource + + +class TestExceptions: + + def test_exception_contains_error_in_response(self, http): + class ThrowingResource(BaseResource): + def read(self): + raise exceptions.BadRequest({'test': 'testing'}) + + http.app.register(ThrowingResource, name='error') + + response = http.get('/error') + + assert response.status_code == 400 + assert response.json == {'test': 'testing'} + assert response.headers['Content-Type'] == 'application/json' + + def test_500_error_has_no_content(self, http): + class TypeErrorResource(BaseResource): + def read(self): + raise TypeError + http.app.register(TypeErrorResource, name='error') + return TypeErrorResource + + response = http.get('/error') + + assert response.status_code == 500 + assert response.headers['Content-Type'] == 'text/plain' From 438fa76d84df9fba5332eabfe197bfad7dca83f1 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 16:29:41 -0700 Subject: [PATCH 110/118] also roll back the database (even though it makes very little difference) --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4d12072..9424a25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,11 +26,12 @@ def db(): @pytest.yield_fixture(scope='function') def session(request, db): """Adds a sqlalchemy session object to the test.""" - request.instance.session = db.Session() + request.instance.session = session = db.Session() yield request.instance.session - request.instance.session.close() + session.rollback() + session.close() class MyResponse(werkzeug.Response): From 40a7a43d2112f56135eb41a07be192cb1a804a55 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 16:54:53 -0700 Subject: [PATCH 111/118] Remove the defunct test_codec stuff, just test the media_range functionality. --- tests/test_codecs.py | 79 ---------------------------------------- tests/test_encoders.py | 39 ++++++++++++++++++++ tests/test_exceptions.py | 1 + 3 files changed, 40 insertions(+), 79 deletions(-) delete mode 100644 tests/test_codecs.py diff --git a/tests/test_codecs.py b/tests/test_codecs.py deleted file mode 100644 index 4085df3..0000000 --- a/tests/test_codecs.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest -from pytest import mark -from armet.codecs import CodecRegistry - - -class ExampleEncoder: - pass - - -class CounterExampleEncoder: - pass - - -@mark.xfail -class TestCodecRegistry: - - def setup(self): - self.registry = CodecRegistry() - self.registry.register( - names=['test', 'example'], - mime_types=['application/octet-stream', 'test/test'])( - ExampleEncoder) - - self.registry.register( - mime_types=['application/xbel+xml', 'example/xml'])( - CounterExampleEncoder) - - def test_remove_by_object(self): - self.registry.remove(ExampleEncoder) - pytest.raises(KeyError, self.registry.find, name="test") - - def test_register_nothing(self): - with pytest.raises(AssertionError): - @self.registry.register() - def test(): - pass - - def test_remove_by_name(self): - self.registry.remove(name="example") - pytest.raises(KeyError, self.registry.find, name="example") - - def test_remove_by_mime_type(self): - self.registry.remove(mime_type="example/xml") - pytest.raises(KeyError, self.registry.find, mime_type="example/xml") - - def test_remove_by_value(self): - self.registry.remove(ExampleEncoder) - pytest.raises(KeyError, self.registry.find, name='example') - - def test_lookup_by_mime_type(self): - mime = 'test/test' - assert self.registry.find(mime_type=mime) == ExampleEncoder - - mime = 'application/octet-stream' - assert self.registry.find(mime_type=mime) == ExampleEncoder - - def test_lookup_by_media_range(self): - mime = 'example/*;q=0.5,*/*; q=0.1' - assert self.registry.find(media_range=mime) == CounterExampleEncoder - - mime = 'test/*;q=0.5,*/*; q=0.1' - assert self.registry.find(media_range=mime) == ExampleEncoder - - def test_malformed_media_range(self): - with pytest.raises(KeyError): - self.registry.find(media_range='asdf') - - def test_lookup_by_name(self): - assert self.registry.find(name='test') == ExampleEncoder - assert self.registry.find(name='example') == ExampleEncoder - - def test_lookup_failure(self): - pytest.raises(KeyError, self.registry.find, name='missing') - mime = 'application/missing;q=0.5' - pytest.raises(KeyError, self.registry.find, media_range=mime) - - def test_lookup_bad_args(self): - with pytest.raises(TypeError): - self.registry.find() diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 565a186..0e61839 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -14,6 +14,45 @@ def test_encoders_api_methods(): assert encoders.remove +@pytest.fixture(scope='function') +def registry(): + return encoders.CodecRegistry() + + +@pytest.fixture(scope='function') +def example_encoders(registry): + # Create some example objects that can operate as encoders. This works + # Becuase the registry is just in charge of registering and returning + # things (no introspection) + example = type('example', (), {}) + counter = type('example', (), {}) + + registry.register( + name=['test', 'example'], + mime_type=['application/octet-stream', 'test/test'])(example) + registry.register( + mime_type=['application/xbel+xml', 'example/xml'])(counter) + + return example, counter + + +@pytest.mark.usefixtures('example_encoders') +def test_lookup_by_media_range(registry, example_encoders): + example, counter = example_encoders + + mime = 'example/*;q=0.5,*/*; q=0.1' + assert registry.find(media_range=mime)[0] == counter + + mime = 'test/*;q=0.5,*/*; q=0.1' + assert registry.find(media_range=mime)[0] == example + + +@pytest.mark.usefixtures('example_encoders') +def test_malformed_media_range(registry): + with pytest.raises(KeyError): + registry.find(media_range='asdf') + + class BaseEncoderTest: def encode(self, data): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c4c57d0..a941a01 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -28,3 +28,4 @@ def read(self): assert response.status_code == 500 assert response.headers['Content-Type'] == 'text/plain' + assert len(response.data) == 0 From 59eb7fc7d6a5a8b2dcba1e1a97472bc026490e53 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 16:55:26 -0700 Subject: [PATCH 112/118] Remove superfluous decorator --- tests/test_encoders.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_encoders.py b/tests/test_encoders.py index 0e61839..3a6eb19 100644 --- a/tests/test_encoders.py +++ b/tests/test_encoders.py @@ -36,7 +36,6 @@ def example_encoders(registry): return example, counter -@pytest.mark.usefixtures('example_encoders') def test_lookup_by_media_range(registry, example_encoders): example, counter = example_encoders From 14033445af6e1f51dbe0afb01607155adb6e5563 Mon Sep 17 00:00:00 2001 From: Erich Healy Date: Tue, 15 Jul 2014 17:02:19 -0700 Subject: [PATCH 113/118] Use the response object's charset for decoding instead of assuming utf8 encoding --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9424a25..aac9073 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,7 @@ class MyResponse(werkzeug.Response): @property def json(self): - return json.loads(self.data.decode('utf-8')) + return json.loads(self.data.decode(self.charset)) class HTTP: From 35a497c13e17bcbd03e4ec03f9e60a5c525c2146 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 22 Jul 2014 16:08:27 -0700 Subject: [PATCH 114/118] Update. --- armet/api.py | 2 +- armet/contrib/__init__.py | 0 armet/contrib/sqla/__init__.py | 0 armet/contrib/sqla/resources.py | 51 ++++++++++ armet/resources.py | 175 ++++++++++++++++++++++++++++++++ armet/resources/__init__.py | 9 +- armet/resources/sqlalchemy.py | 43 -------- setup.py | 4 +- 8 files changed, 232 insertions(+), 52 deletions(-) create mode 100644 armet/contrib/__init__.py create mode 100644 armet/contrib/sqla/__init__.py create mode 100644 armet/contrib/sqla/resources.py create mode 100644 armet/resources.py delete mode 100644 armet/resources/sqlalchemy.py diff --git a/armet/api.py b/armet/api.py index 5c012a3..68da621 100644 --- a/armet/api.py +++ b/armet/api.py @@ -188,7 +188,7 @@ def _find(self, path): # Invoke `.read` and store it in the context; we are not # at the final segment in the url. - context[name] = resource.read() + context[name] = resource.filter(resource.read(), None) # Update the `last_resource_cls` (keeps track of the # immediate-left resource) diff --git a/armet/contrib/__init__.py b/armet/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/armet/contrib/sqla/__init__.py b/armet/contrib/sqla/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py new file mode 100644 index 0000000..33610a7 --- /dev/null +++ b/armet/contrib/sqla/resources.py @@ -0,0 +1,51 @@ +from armet.resources import Resource + + +class SQLAlchemyResource(Resource): + + # The model class object that this sqlalchemy resource will use. + model = None + + # The session constructor used to build a session for this resource. + session = None + + # Relationships to facilitate. + # NOTE: Should be a dictionary of names to models (for now) + # FIXME: Should be able to "look up the model" + relationships = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # This behaves as a hook to assert the set of attributes is ordered + # and begins with the slug attribute + + # FIXME: The slug_attribute shouldn't need to be an actual attribute + + # TODO: this is executed every time an instance is constructed. + # move this to some kind of metaclass or metaclass hook. + attrs = set(self.attributes) + attrs.discard(self.slug_attribute) + self.attributes = [self.slug_attribute] + list(attrs) + + def read(self): + # TODO: Column set could be cached + columns = (getattr(self.model, name) for name in self.attributes) + return self.session().query(*columns) + + def filter(self, queryset, query): + # Apply the slug to the queryset + if self.slug is not None: + # TODO: Slug column could be cached + slug_column = getattr(self.model, self.slug_attribute) + queryset = queryset.filter(slug_column == self.slug) + + # Apply the relationship context to the queryset + for name, relation in self.relationships.items(): + if name in self.context: + queryset = queryset.join(relation).filter( + self.context[name].whereclause) + + del self.context[name] + + return queryset diff --git a/armet/resources.py b/armet/resources.py new file mode 100644 index 0000000..dd0a87b --- /dev/null +++ b/armet/resources.py @@ -0,0 +1,175 @@ + + +class CycleRegistry(dict): + + def __init__(self, fallback=None): + super().__init__() + + # A fallback dictionary to use if the key is not in this dictionary. + self._fallback = fallback + + def __missing__(self, key): + # If the key is a "type" we should attempt to iterate back through + # its MRO to resolve it based on inheritance. + if isinstance(key, type): + for cls in key.__mro__: + if cls in self: + # Cache this for the next lookup. + self[key] = self[cls] + + # Return the result. + return self[cls] + + # If we have a fallback dictionary, use that. + if self._fallback: + return self._fallback[key] + + # Raise a KeyError as we don't have this. + raise KeyError + + +# Registry of preparation functions (by name and type) +_prepares = CycleRegistry() + +# Registry of cleaning functions (by name and type) +_cleans = CycleRegistry() + + +class Resource: + + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + + # Set of resource names that can be traversed to from this resource. + relationships = set() + + def __init__(self, slug=None, context=None): + """ + :param slug: Identifier that represents which item of the resource + to return, if present. + :type context: str or None + + :param context: Context in which this resource is being called (eg. + a nested resource will receive the result of a `read` + from the parent resource in its context). + :type context: dict or None + """ + self.slug = slug + self.context = context or {} + + def prepare_item(self, item): + # data = OrderedDict() + data = {} + for name in self.attributes: + try: + # Attempt to get the attribute from the item. + value = getattr(item, name) + + except AttributeError: + # Item does not have the attribute; just put `None` + # in the object. + data[name] = None + continue + + try: + # Attempt to get a preparation function, by type. + prepare = _prepares[type(value)] + + except KeyError: + try: + # Attempt to get a preparation function, by name. + prepare = _prepares[name] + + except KeyError: + # No preparation function; continue. + data[name] = value + continue + + # Prepare and push the attribute in the object. + data[name] = prepare(item, name, value) + + return data + + def prepare(self, items): + return [self.prepare_item(item) for item in items] + + +def prepares(*clauses): + """ + Registers a preparation function to be invoked for each clause in + the decorator. + + A preparation clause is invoked on each attribute after it is retrieved + from the item returned from the `read` function. + + A clause may be a string or a type. If it is a string then the clause + describes an attribute of a resource by name. If it is a type then the + clause would be applied to all attributes of that type (or are an instance + of that type). + + A preparation function may be "scoped" to a specific resource + by decorating a method (instead of a module function). + + :: + @prepares(datetime.datetime) + def prepare_datetime(item, key, value): + return value.isoformat() + + @prepares("id") + def prepare_id(item, key, value): + return str(value) + + class User(Resource): + + @prepares("id") + def prepare_id(self, item, key, value): + return value.hex + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _prepares[clause] = function + + # Return the original function. + return function + + return decorator + + +def cleans(*clauses): + """ + Registers a cleaning function to be invoked for each clause in + the decorator. + + A cleaning clause is invoked on each attribute after it is received + from the decoder and before it is sent to the `update` or `create` + functions. + + Otherwise the semantics are equivalent to the `prepares` decorator. + + :: + @cleans(datetime.datetime) + def clean_datetime(key, value): + from dateutil import parse + return parse(value) + + @cleans("id") + def clean_id(key, value): + return UUID(value) + + class User(Resource): + + @cleans("id") + def clean_id(self, key, value): + return UUID(value) + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _cleans[clause] = function + + # Return the original function. + return function + + return decorator diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py index aeb6b64..8082724 100644 --- a/armet/resources/__init__.py +++ b/armet/resources/__init__.py @@ -1,12 +1,9 @@ -from .registry import CycleRegistry, prepares, cleans -from .base import Resource, ResourceMeta -from .sqlalchemy import SQLAlchemyResource +from .registry import prepares, cleans +from .base import Resource + __all__ = [ 'Resource', - 'CycleRegistry', 'prepares', 'cleans', - 'SQLAlchemyResource', - 'ResourceMeta', ] diff --git a/armet/resources/sqlalchemy.py b/armet/resources/sqlalchemy.py deleted file mode 100644 index e067ab6..0000000 --- a/armet/resources/sqlalchemy.py +++ /dev/null @@ -1,43 +0,0 @@ -from .base import Resource - - -class SQLAlchemyResource(Resource): - - # The model class object that this sqlalchemy resource will use. - model = None - - # The session constructor used to build a session for this resource. - session = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # This behaves as a hook to assert the set of attributes is ordered - # and begins with the slug attribute - - # TODO: this is executed every time an instance is constructed. - # move this to some kind of metaclass or metaclass hook. - attrs = set(self.attributes) - attrs.discard(self.slug_attribute) - self.attributes = [self.slug_attribute] + list(attrs) - - def read(self): - """Return a sqlalchemy query object of columns.""" - columns = (getattr(self.model, name) for name in self.attributes) - return self.session().query(*columns) - - def filter(self, saquery, armetquery): - """Filter hook! Used to filter the slug and any armet queries.""" - - # Filter by the slug. - if self.slug is not None: - saquery = saquery.filter_by(**{self.slug_attribute: self.slug}) - - # TODO: Armet query filtering. - return saquery - - def prepare(self, items): - # Turn models into dictionaries! - return [self.prepare_item(x) for x in items] - - def prepare_item(self, item): - return dict(zip(self.attributes, item)) diff --git a/setup.py b/setup.py index bce1313..cbab837 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,6 @@ def run(self): 'pytest-pep8', 'pytest-cov', 'pytest-bench', - 'python-mimeparse', 'pytest', ] @@ -69,7 +68,8 @@ def run(self): packages=find_packages('.'), install_requires=[ 'ujson', - 'werkzeug' + 'werkzeug', + 'python-mimeparse' ], cmdclass={'test': PyTest}, extras_require=extras_require, From aca3e011869e2233e3f5a0c6d4837a0cfa464c56 Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Tue, 26 Aug 2014 23:01:03 -0700 Subject: [PATCH 115/118] Some work towards getting a more complete API. --- armet/contrib/sqla/resources.py | 152 +++++++++++++++++++++++++------- armet/encoders/json.py | 3 +- armet/resources/base.py | 40 ++++++--- armet/utils.py | 28 +++++- 4 files changed, 174 insertions(+), 49 deletions(-) diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py index 33610a7..a1b5b5d 100644 --- a/armet/contrib/sqla/resources.py +++ b/armet/contrib/sqla/resources.py @@ -1,51 +1,139 @@ -from armet.resources import Resource +import sqlalchemy as sa +from armet.resources.base import Resource, ResourceMeta +from armet.utils import classproperty, memoize +from sqlalchemy.orm import ColumnProperty, RelationshipProperty +# Mapping of 'Model' classes to canonical 'Resources' +_resources = {} -class SQLAlchemyResource(Resource): +# Mapping of 'Tables' to 'Models' +_models = {} - # The model class object that this sqlalchemy resource will use. - model = None - # The session constructor used to build a session for this resource. - session = None +class SQLAlchemyResourceMeta(ResourceMeta): - # Relationships to facilitate. - # NOTE: Should be a dictionary of names to models (for now) - # FIXME: Should be able to "look up the model" - relationships = {} + def __init__(self, name, bases, attrs): + super().__init__(name, bases, attrs) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + if self._meta.model is not None: + # Emplace ourself in the mapping for this model + # TODO: We should be able to make a non-canonical resource + _resources[self._meta.model] = self + _models[self._meta.model.__table__] = self._meta.model - # This behaves as a hook to assert the set of attributes is ordered - # and begins with the slug attribute + if hasattr(self._meta, "attributes"): + self.attributes = attrs = list(self._meta.attributes) - # FIXME: The slug_attribute shouldn't need to be an actual attribute + # if self._meta.model is not None: + # # Cache the column set that will be fetched + # self._columns = [] + # self._paths = [] + # for name in attrs: + # path = name.split(".") + # attr = getattr(self._meta.model, path[0]) + # if attr.impl is not None: + # if len(path) == 1 and attr.impl.accepts_scalar_loader: + # # This is a scalar column and can be easily fetched + # self._columns.append(attr) + # + # else: + # # This is a "relationship" attribute or path + # # that is to be embedded + # self._paths.append(path) - # TODO: this is executed every time an instance is constructed. - # move this to some kind of metaclass or metaclass hook. - attrs = set(self.attributes) - attrs.discard(self.slug_attribute) - self.attributes = [self.slug_attribute] + list(attrs) + +class SQLAlchemyResource(Resource, metaclass=SQLAlchemyResourceMeta): + + class Meta: + # The model class object that this sqlalchemy resource will use. + model = None + + # The session constructor used to build a session for this resource. + session = None + + # Relationships to facilitate. + relationships = [] + + @classproperty + @memoize + def _paths(cls): + paths = [] + for name in cls.attributes: + path = name.split(".") + attr = getattr(cls._meta.model, path[0]) + if len(path) > 1 or not isinstance( + attr.property, ColumnProperty): + # This is /not/ a scalar column attribute + paths.append(path) + + return paths + + @classproperty + @memoize + def _columns(cls): + columns = [] + for name in cls.attributes: + path = name.split(".") + attr = getattr(cls._meta.model, path[0]) + if len(path) == 1 and isinstance(attr.property, ColumnProperty): + # This is a scalar column and can be easily fetched + columns.append(attr) + + return columns def read(self): - # TODO: Column set could be cached - columns = (getattr(self.model, name) for name in self.attributes) - return self.session().query(*columns) + # Build the base queryset (over each scalar column) + queryset = self._meta.session().query(*self._columns) + + # Iterate each `path` + entities = [] + for path in self._paths: + # Build the selectable for the end-result and join the + # neccessary table(s) to get there + selectable = None + entity = True + for segment in path: + attr = getattr(self._meta.model, segment) + + # Get the `target_table` and `target` and make that + # the selectable + target_table = attr.property.target + target = _models[target_table] + selectable = target + + # Join the table to us in our quest to get this attribute + queryset = queryset.outerjoin(target, attr.expression) + + if entity: + queryset = queryset.add_entity(selectable) + + return queryset def filter(self, queryset, query): # Apply the slug to the queryset if self.slug is not None: - # TODO: Slug column could be cached - slug_column = getattr(self.model, self.slug_attribute) + slug_column = getattr( + self._meta.model, self._meta.slug_attribute) queryset = queryset.filter(slug_column == self.slug) - # Apply the relationship context to the queryset - for name, relation in self.relationships.items(): - if name in self.context: - queryset = queryset.join(relation).filter( - self.context[name].whereclause) - - del self.context[name] return queryset + + @classmethod + def prepare_item(cls, item): + # Prepare the initial (scalar) dataset + data = super().prepare_item(item) + + # Iterate through our relationships (and prepare and embed) + # cnt = len(cls._columns) + # for idx, path in enumerate(cls._paths): + # data[path[-1]] = item[cnt + idx] + # attr = getattr(cls._meta.model, key) + # target_table = attr.property.target + # target = _models[target_table] + # resource = _resources[target] + # value = getattr(item, target.__name__, None) + # if value is not None: + # data[key] = resource.prepare_item(value) + + return data diff --git a/armet/encoders/json.py b/armet/encoders/json.py index 7d737f4..f7b0efd 100644 --- a/armet/encoders/json.py +++ b/armet/encoders/json.py @@ -1,4 +1,5 @@ -import ujson as json +# import ujson as json +import json from collections import Iterable from ..codecs import JSONCodec diff --git a/armet/resources/base.py b/armet/resources/base.py index 79be3a5..315ba51 100644 --- a/armet/resources/base.py +++ b/armet/resources/base.py @@ -1,4 +1,5 @@ from .registry import _prepare +import collections class ResourceMeta(type): @@ -6,10 +7,17 @@ class ResourceMeta(type): def __init__(self, name, bases, attrs): super().__init__(name, bases, attrs) + # Collect `Meta` information and store on `_meta` + # This aggregates all base class `Meta` information metadata = {} for base in bases: - metadata.update(getattr(base, "_meta", {})) + _meta = getattr(base, "_meta", None) + if _meta is None: + _meta = {} + else: + _meta = vars(_meta) + metadata.update(_meta) meta = attrs.get("Meta") if meta: @@ -17,17 +25,17 @@ def __init__(self, name, bases, attrs): if not name.startswith("_"): metadata[name] = value + # Create and store the metadata as `_meta` + self._meta = type("Meta", (), metadata) -class Resource(metaclass=ResourceMeta): - # Set of named attributes to faclitiate from the `item` returned - # from the `read` method. - attributes = set() - relationships = set() +class Resource(metaclass=ResourceMeta): class Meta: - # options = () - pass + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + relationships = set() def __init__(self, slug=None, context=None): """ @@ -43,9 +51,11 @@ def __init__(self, slug=None, context=None): self.slug = slug self.context = context or {} - def prepare_item(self, item): - data = {} - for name in self.attributes: + @classmethod + def prepare_item(cls, item): + # data = {} + data = collections.OrderedDict() + for name in cls.attributes: try: # Attempt to get the attribute from the item. value = getattr(item, name) @@ -63,9 +73,11 @@ def prepare_item(self, item): return data - def filter(self, saquery, armetquery): - # TODO: Armet filtering here. - return saquery + def filter(self, items, query): + # TODO: Apply `slug` filtering + # TODO: Apply `context` filtering + # TODO: Apply `query` filtering + return query def prepare(self, items): return [self.prepare_item(item) for item in items] diff --git a/armet/utils.py b/armet/utils.py index 702a62d..6da104a 100644 --- a/armet/utils.py +++ b/armet/utils.py @@ -1,6 +1,6 @@ -from collections import OrderedDict +import functools import itertools - +import warnings def dasherize(text): result = '' @@ -46,3 +46,27 @@ def merge_headers(*headers): exist.add(entry[0]) new.append(entry) return new + + +def memoize(obj): + cache = obj.cache = {} + + @functools.wraps(obj) + def memoizer(*args, **kwargs): + key = str(args) + str(kwargs) + if key not in cache: + cache[key] = obj(*args, **kwargs) + return cache[key] + + return memoizer + + +class classproperty(object): + """Declares a read-only `property` that acts on the class object. + """ + + def __init__(self, getter): + self.getter = getter + + def __get__(self, obj, cls): + return self.getter(cls) From 9eabadefd2567c486be02aa5ca1bd8835da56a6c Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 27 Aug 2014 14:43:30 -0700 Subject: [PATCH 116/118] Update. --- armet/contrib/sqla/resources.py | 139 +++++++++++++++++++------------- armet/encoders/json.py | 3 +- armet/resources/base.py | 20 +++-- 3 files changed, 96 insertions(+), 66 deletions(-) diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py index a1b5b5d..1b5a1e1 100644 --- a/armet/contrib/sqla/resources.py +++ b/armet/contrib/sqla/resources.py @@ -2,6 +2,8 @@ from armet.resources.base import Resource, ResourceMeta from armet.utils import classproperty, memoize from sqlalchemy.orm import ColumnProperty, RelationshipProperty +from sqlalchemy.orm.interfaces import MapperProperty +from sqlalchemy.ext.associationproxy import AssociationProxy # Mapping of 'Model' classes to canonical 'Resources' _resources = {} @@ -24,23 +26,6 @@ def __init__(self, name, bases, attrs): if hasattr(self._meta, "attributes"): self.attributes = attrs = list(self._meta.attributes) - # if self._meta.model is not None: - # # Cache the column set that will be fetched - # self._columns = [] - # self._paths = [] - # for name in attrs: - # path = name.split(".") - # attr = getattr(self._meta.model, path[0]) - # if attr.impl is not None: - # if len(path) == 1 and attr.impl.accepts_scalar_loader: - # # This is a scalar column and can be easily fetched - # self._columns.append(attr) - # - # else: - # # This is a "relationship" attribute or path - # # that is to be embedded - # self._paths.append(path) - class SQLAlchemyResource(Resource, metaclass=SQLAlchemyResourceMeta): @@ -56,56 +41,64 @@ class Meta: @classproperty @memoize - def _paths(cls): - paths = [] + def _segment_attributes(cls): + columns = [] + rels = [] + calculated = [] for name in cls.attributes: - path = name.split(".") - attr = getattr(cls._meta.model, path[0]) - if len(path) > 1 or not isinstance( - attr.property, ColumnProperty): + attr = getattr(cls._meta.model, name) + if (hasattr(attr, "property") + and isinstance(attr.property, ColumnProperty)): + # This is a scalar column and can be easily fetched + columns.append(attr) + elif (hasattr(attr, "property") + and isinstance(attr.property, MapperProperty)): # This is /not/ a scalar column attribute - paths.append(path) + # but is stil sqlalchemy-y + rels.append(name) + else: + calculated.append(name) - return paths + return columns, rels, calculated @classproperty - @memoize - def _columns(cls): - columns = [] - for name in cls.attributes: - path = name.split(".") - attr = getattr(cls._meta.model, path[0]) - if len(path) == 1 and isinstance(attr.property, ColumnProperty): - # This is a scalar column and can be easily fetched - columns.append(attr) + def _calculated(cls): + return cls._segment_attributes[2] - return columns + @classproperty + def _relationships(cls): + return cls._segment_attributes[1] + + @classproperty + def _columns(cls): + return cls._segmented_attributes[0] def read(self): # Build the base queryset (over each scalar column) - queryset = self._meta.session().query(*self._columns) + queryset = self._meta.session().query(self._meta.model) - # Iterate each `path` + # Iterate each `relationship` entities = [] - for path in self._paths: + for rel in self._relationships: # Build the selectable for the end-result and join the # neccessary table(s) to get there - selectable = None - entity = True - for segment in path: - attr = getattr(self._meta.model, segment) - + attr = getattr(self._meta.model, rel) + direction = attr.property.direction.name + if direction == "MANYTOONE": # Get the `target_table` and `target` and make that # the selectable target_table = attr.property.target target = _models[target_table] - selectable = target # Join the table to us in our quest to get this attribute queryset = queryset.outerjoin(target, attr.expression) - if entity: - queryset = queryset.add_entity(selectable) + else: + # TO-MANY attributes are added later + continue + + # Add the 'enttiy' to the queryset + queryset = queryset.add_entity(target) return queryset @@ -116,24 +109,54 @@ def filter(self, queryset, query): self._meta.model, self._meta.slug_attribute) queryset = queryset.filter(slug_column == self.slug) - return queryset @classmethod def prepare_item(cls, item): + # If we didn't get a (tuple,) ... + if not isinstance(item, tuple): + item = (item,) + + # Check to see if we need to polymorphically + # apply a different resource + if type(item[0]) is not cls._meta.model: + resource = _resources.get(type(item[0])) + if resource is not None: + return resource.prepare_item(item) + # Prepare the initial (scalar) dataset - data = super().prepare_item(item) + data = super().prepare_item(item[0]) # Iterate through our relationships (and prepare and embed) - # cnt = len(cls._columns) - # for idx, path in enumerate(cls._paths): - # data[path[-1]] = item[cnt + idx] - # attr = getattr(cls._meta.model, key) - # target_table = attr.property.target - # target = _models[target_table] - # resource = _resources[target] - # value = getattr(item, target.__name__, None) - # if value is not None: - # data[key] = resource.prepare_item(value) + for idx, rel in enumerate(cls._relationships): + + # FIXME: This could be calculated in `read` + attr = getattr(cls._meta.model, rel) + direction = attr.property.direction.name + target_table = attr.property.target + target = _models[target_table] + + resource = _resources[target] + + if direction == "MANYTOONE": + # Get the value and prepare it appropriately + value = item[1 + idx] + + if value is not None: + cls._set_item(data, rel, resource.prepare_item(value)) + + else: + cls._set_item(data, rel, None) + + elif direction == "ONETOMANY": + # Get the values + values = getattr(item[0], rel) + + # Prepare each value and add to the data + cls._set_item(data, rel, resource.prepare(values)) + + # Apply calculated attributes + for name in cls._calculated: + cls._set_item(data, name, getattr(item[0], name)) return data diff --git a/armet/encoders/json.py b/armet/encoders/json.py index f7b0efd..7d737f4 100644 --- a/armet/encoders/json.py +++ b/armet/encoders/json.py @@ -1,5 +1,4 @@ -# import ujson as json -import json +import ujson as json from collections import Iterable from ..codecs import JSONCodec diff --git a/armet/resources/base.py b/armet/resources/base.py index 315ba51..c6c438c 100644 --- a/armet/resources/base.py +++ b/armet/resources/base.py @@ -1,5 +1,4 @@ from .registry import _prepare -import collections class ResourceMeta(type): @@ -35,6 +34,7 @@ class Meta: # Set of named attributes to faclitiate from the `item` returned # from the `read` method. attributes = set() + attribute_labels = {} relationships = set() def __init__(self, slug=None, context=None): @@ -53,8 +53,7 @@ def __init__(self, slug=None, context=None): @classmethod def prepare_item(cls, item): - # data = {} - data = collections.OrderedDict() + data = {} for name in cls.attributes: try: # Attempt to get the attribute from the item. @@ -69,15 +68,24 @@ def prepare_item(cls, item): value = None continue - data[name] = value + cls._set_item(data, name, value) return data + @classmethod + def _set_item(cls, data, name, value): + if name in cls._meta.attribute_labels: + data[cls._meta.attribute_labels[name]] = value + + else: + data[name] = value + def filter(self, items, query): # TODO: Apply `slug` filtering # TODO: Apply `context` filtering # TODO: Apply `query` filtering return query - def prepare(self, items): - return [self.prepare_item(item) for item in items] + @classmethod + def prepare(cls, items): + return [cls.prepare_item(item) for item in items] From a81080c9d06503f820f6be7745c75a5c699acfff Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 27 Aug 2014 20:30:18 -0700 Subject: [PATCH 117/118] Update. --- armet/api.py | 46 +++++++++++++++++++++++++++++---- armet/contrib/sqla/resources.py | 11 ++++++++ armet/registry.py | 7 +++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/armet/api.py b/armet/api.py index 68da621..496942f 100644 --- a/armet/api.py +++ b/armet/api.py @@ -271,6 +271,10 @@ def encode(self, request, response, data): raise exceptions.NotAcceptable() from ex def route(self, request, response): + # Store both the request and response on `self` + self.request = request + self.response = response + # Find and instantiate the right-most resource using path traversal. resource = self._find(request.path) @@ -288,10 +292,13 @@ def route(self, request, response): raise exceptions.MethodNotAllowed([request.method]) try: - # Dispatch the request. response_data = route(resource, request_data) - if response_data is None: + status_code = 200 + if isinstance(response_data, tuple): + response_data, status_code = response_data + + elif response_data is None: response.status_code = 204 return response @@ -311,12 +318,14 @@ def route(self, request, response): # This response will still go through the encoding cycle to encode # any error messages. response = ex.get_response() + else: # Return a successful response. - response.status_code = 200 + response.status_code = status_code - # Write the response data into the response object - self.encode(request, response, response_data) + if response_data is not None: + # Write the response data into the response object + self.encode(request, response, response_data) return response @@ -333,7 +342,34 @@ def get(self, resource, data=None): try: return resource.prepare_item(items[0]) + except IndexError: + raise exceptions.NotFound() + except TypeError: return resource.prepare_item(items) return resource.prepare(items) + + def post(self, resource, data): + try: + # Ask the resource to `create` (should return the new item) + item = resource.create(data) + slug = resource.slug_for(item) + + except AttributeError: + raise exceptions.NotImplemented() + + # Set the `Location` header to indicate where we created the item + self.response.headers["Location"] = self.url_for( + resource, slug=slug) + + # Return nothing and a 201 to indicate creation + return None, 201 + + def url_for(self, resource, slug=None): + name = self._registry.rfind(type(resource)) + slug = "/%s" % slug if slug else "" + return "{}/{}{slug}".format( + self.request.script_root, + name, + slug=slug) diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py index 1b5a1e1..8023c13 100644 --- a/armet/contrib/sqla/resources.py +++ b/armet/contrib/sqla/resources.py @@ -160,3 +160,14 @@ def prepare_item(cls, item): cls._set_item(data, name, getattr(item[0], name)) return data + + def create(self, data): + target = self._meta.model(**data) + + self._meta.session().add(target) + self._meta.session().commit() + + return target + + def slug_for(self, item): + return getattr(item, self._meta.slug_attribute) diff --git a/armet/registry.py b/armet/registry.py index c6669dc..926e2c3 100644 --- a/armet/registry.py +++ b/armet/registry.py @@ -48,6 +48,13 @@ def callback(obj): # Just invoke the callback directly callback(obj) + def rfind(self, value): + # FIXME: Implement a cache on the value + for key_name, registry in self.map.items(): + for key, test in registry.items(): + if test is value: + return key + def find(self, **kwargs): if len(kwargs) > 1: raise TypeError( From f4b76604fa6f57f0836a0828206367db741c931c Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Wed, 27 Aug 2014 23:16:49 -0700 Subject: [PATCH 118/118] Update. --- armet/api.py | 37 ++++++++++++++++++++++++++------- armet/contrib/sqla/resources.py | 22 ++++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/armet/api.py b/armet/api.py index 496942f..ceb03db 100644 --- a/armet/api.py +++ b/armet/api.py @@ -350,6 +350,14 @@ def get(self, resource, data=None): return resource.prepare(items) + def url_for(self, resource, slug=None): + name = self._registry.rfind(type(resource)) + slug = "/%s" % slug if slug else "" + return "{}/{}{slug}".format( + self.request.script_root, + name, + slug=slug) + def post(self, resource, data): try: # Ask the resource to `create` (should return the new item) @@ -366,10 +374,25 @@ def post(self, resource, data): # Return nothing and a 201 to indicate creation return None, 201 - def url_for(self, resource, slug=None): - name = self._registry.rfind(type(resource)) - slug = "/%s" % slug if slug else "" - return "{}/{}{slug}".format( - self.request.script_root, - name, - slug=slug) + def patch(self, resource, data): + if resource.slug is None: + # We don't support mass-updates (yet) + raise exceptions.NotImplemented() + + # Fetch the item to update + items = resource.filter(resource.read(), None) + try: + item = items[0] + + except IndexError: + raise exceptions.NotFound() + + try: + # Ask the resource to `update` + resource.update(item, data) + + except AttributeError: + raise exceptions.NotImplemented() + + # Return nothing to indicate success + return None diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py index 8023c13..da93245 100644 --- a/armet/contrib/sqla/resources.py +++ b/armet/contrib/sqla/resources.py @@ -50,7 +50,7 @@ def _segment_attributes(cls): if (hasattr(attr, "property") and isinstance(attr.property, ColumnProperty)): # This is a scalar column and can be easily fetched - columns.append(attr) + columns.append(name) elif (hasattr(attr, "property") and isinstance(attr.property, MapperProperty)): # This is /not/ a scalar column attribute @@ -71,7 +71,7 @@ def _relationships(cls): @classproperty def _columns(cls): - return cls._segmented_attributes[0] + return cls._segment_attributes[0] def read(self): # Build the base queryset (over each scalar column) @@ -161,13 +161,27 @@ def prepare_item(cls, item): return data + def slug_for(self, item): + return getattr(item, self._meta.slug_attribute) + def create(self, data): target = self._meta.model(**data) + # Add the new model to the session (and flush) self._meta.session().add(target) + self._meta.session().flush() + + # Finalize the transaction self._meta.session().commit() return target - def slug_for(self, item): - return getattr(item, self._meta.slug_attribute) + def update(self, item, data): + # Set the simple (scalar) attributes + for name in self._columns: + value = data.get(name) + if value: + setattr(item, name, value) + + # Finalize the transaction + self._meta.session().commit()