import abc
import collections
import functools
import inspect
import itertools
import uuid
from collections.abc import Mapping
from functools import partial
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.routing import BuildError as URLBuildError
from werkzeug.utils import cached_property, validate_arguments
from findig.content import ErrorHandler, Formatter, Parser
from findig.context import url_adapter, request, ctx
from findig.data_model import DataModel, DataSetDataModel, DictDataModel
[docs]class AbstractResource(metaclass=abc.ABCMeta):
"""
Represents a very low-level web resource to be handled by Findig.
Findigs apps are essentially a collection of routed resources. Each
resource is expected to be responsible for handling some requests to
a set of one or more URLs. When requests to such a URL is received,
Findig looks-up what resource is responsible, and hands the request
object over to the resource for processing.
Custom implementations of the abstract class are possible. However,
this class operates at a very low level in the Findig stack, so it is
recommended that they are only used for extreme cases where those
low-level operations are needed.
In addition to the methods defined here, resources should have a
name attribute, which is a string that uniquely identifies it within
the app. Optional *parser* and *formatter* attributes corresponding to
:class:`findig.content.AbstractParser` and
:class:`finding.content.AbstractFormatter` instances respectively,
will also be used if added.
"""
@abc.abstractmethod
[docs] def get_supported_methods(self):
"""
Return a Python set of HTTP methods to be supported by the resource.
"""
@abc.abstractmethod
[docs] def handle_request(self, request, url_values):
"""
Handle a request to one of the resource URLs.
:param request: An object encapsulating information about the
request. It is the same as
:py:data:`findig.context.request`.
:type request: :class:`~findig.wrappers.Request`, which
in turn is a subclass of
:py:class:`werkzeug.wrappers.Request`
:param url_values: A dictionary of arguments that have been parsed
from the URL routes, which may help to better
identify the request. For example, if a resource
is set up to handle URLs matching the rule
``/items/<int:id>`` and a request is sent to
``/items/43``, then *url_values* will be
``{'id': 43}``.
:return: This function should return data that will be transformed
into an HTTP response. This is usually a dictionary, but
depending on how formatting is configured, it may be
any object the output formatter configured for the
resource will accept.
"""
[docs] def build_url(self, values, **args):
"""
build_url(values)
Build a URL for this resource.
The URL is built using the current WSGI environ, so this function
must be called from inside a request context. Furthermore, the
resource must have already been routed to in the current app
(see: :meth:`findig.dispatcher.Dispatcher.route`), and this method
must be passed values for any variables in the URL rule used to
route to the resource.
:param values: Values for the variables in a URL rule used to
route to the resource.
:type values: :class:`dict`
Example::
>>> from findig import App
>>> app = App()
>>> @app.route("/index/<int:num>")
... @app.resource
... def item(num):
... return {}
...
>>> with app.test_context(path="/index/1"):
... item.build_url(dict(num=4))
...
'/index/4'
This method is *not* abstract.
"""
return url_adapter.build(self.name, values, **args)
def __getattr__(self, name):
if getattr(ctx, 'resource', None) is not None:
if ctx.resource is self:
if name in ctx.url_values:
return ctx.url_values[name]
raise AttributeError(name)
[docs]class Resource(AbstractResource):
"""
Resource(wrapped=None, lazy=None, name=None, model=None, formatter=None, parser=None, error_handler=None)
A concrete implementation of :class:`AbstractResource`.
This accepts keyword arguments only.
:keyword wrapped: A function which the resource wraps; it
typically returns the data for that particular
resource.
:keyword lazy: Indicates whether the wrapped resource function
returns lazy resource data; i.e. data is not
retrieved when the function is called, but at some
later point when the data is accessed. Setting this
allows Findig to evaluate the function's return
value after all resources have been declared to
determine if it returns anything useful (for
example, a :class:DataRecord which can be used as
a model).
:keyword name: A name that uniquely identifies the resource.
If not given, it will be randomly generated.
:keyword model: A data-model that describes how to read and write
the resource's data. By default, a generic
:class:`findig.data_model.DataModel` is attached.
:keyword formatter: A function that should be used to format the
resource's data. By default, a generic
:class:`findig.content.Formatter` is attached.
:keyword parser: A function
that should be used to parse request content
for the resource. By default, a generic
:class:`findig.content.Parser` is attached.
:keyword error_handler: A function that should be used to convert
exception into :class:`Responses <werkzeug.wrappers.BaseResponse>`.
By default, a :class:`findig.content.ErrorHandler` is used.
"""
def __init__(self, **args):
self.name = args.get('name', str(uuid.uuid4()))
self.model = args.get('model', DataModel())
self.lazy = args.get('lazy', False)
self.parser = args.get('parser', Parser())
self.formatter = args.get('formatter', Formatter())
if 'error_handler' not in args:
args['error_handler'] = eh = ErrorHandler()
args['error_handler'].register(LookupError, self._on_lookup_err)
self.error_handler = args.get('error_handler')
wrapped = args.get('wrapped', lambda **_: {})
functools.update_wrapper(self, wrapped)
def _on_lookup_err(self, err):
raise NotFound
def __call__(self, **kwargs):
return self.__wrapped__(**kwargs)
[docs] def compose_model(self, wrapper_args=None):
"""
:noindex:
Make a composite model for the resource by combining a
lazy data handler (if present) and the model specified on
the resource.
:param wrapper_args: A set of arguments to call the wrapped
function with, so that a lazy data handler
can be retrieved. If none is given, then
fake data values are passed to the wrapped
function. In this case, the data-model
returned *must not* be used.
:returns: A data-model for the resource
**This is an internal method.**
"""
if self.lazy:
if wrapper_args is None:
# Pass in some fake ass argument values to the wrapper
# so we can get a pretend data-set for inspection.
argspec = inspect.getfullargspec(self.__wrapped__)
wrapper_args = {
name : None for name in
itertools.chain(argspec.args, argspec.kwonlyargs)
}
dataset = self.__wrapped__(**wrapper_args)
dsdm = DataSetDataModel(dataset)
return self.model.compose(dsdm)
elif wrapper_args is not None and 'read' not in self.model:
# Add a 'read' method to the model that just calls this
# model.
new_model = DictDataModel({
'read': lambda: self.__wrapped__(**wrapper_args)
})
return self.model.compose(new_model)
else:
return self.model
[docs] def get_supported_methods(self, model=None):
"""
Return a set of HTTP methods supported by the resource.
:param model: The data-model to use to determine what methods
supported. If none is given, a composite data model
is built from ``self.model`` and any data source
returned by the resource's wrapped function.
"""
model = self.compose_model() if model is None else model
supported_methods = {'GET'}
if 'delete' in model:
supported_methods.add('DELETE')
if 'write' in model:
supported_methods.add('PUT')
return supported_methods
[docs] def handle_request(self, request, wrapper_args):
"""
Dispatch a request to a resource.
See :py:meth:`AbstractResource.handle_request` for accepted
parameters.
"""
method = request.method.upper()
try:
model = self.compose_model(wrapper_args)
handler = self._extract_handler(request, method, model)
args, kwargs = validate_arguments(handler.func, handler.args, wrapper_args)
return handler.func(*args, **kwargs)
except BaseException as err:
return self.error_handler(err)
def _extract_handler(self, request, method, model):
supported_methods = self.get_supported_methods(model)
if method not in supported_methods and method != 'HEAD':
raise MethodNotAllowed(list(supported_methods))
elif method == 'GET' or method == 'HEAD':
return partial(model['read'])
elif method == 'DELETE':
return partial(model['delete'])
elif method == 'PUT':
return partial(model['write'], request.input)
else:
raise ValueError
[docs] def collection(self, wrapped=None, **args):
"""
Create a :class:`Collection` instance
:param wrapped: A wrapped function for the collection. In most
cases, this should be a function that returns an iterable of
resource data.
The keyword arguments are passed on to the constructor for
:class:Collection, except that if no *name* is given, it defaults
to {module}.{name} of the wrapped function.
This function may also be used as a decorator factory::
@resource.collection(include_urls=True)
def mycollection(self):
pass
The decorated function will be replaced in its namespace by a
:class:`Collection` that wraps it. Any keyword arguments
passed to the decorator factory will be handed over to the
:class:`Collection` constructor. If no keyword arguments
are required, then ``@collection`` may be used instead of
``@collection()``.
"""
def decorator(wrapped):
args['wrapped'] = wrapped
args.setdefault(
'name', "{0.__module__}.{0.__qualname__}".format(wrapped))
return Collection(self, **args)
if wrapped is not None:
return decorator(wrapped)
else:
return decorator
[docs]class Collection(Resource):
"""
Collection(of, include_urls=False, bindargs=None, **keywords)
A :class:`Resource` that acts as a collection of other resources.
:param of: The type of resource to be collected.
:type of: :class:`Resource`
:param include_urls: If ``True``, the collection will attempt to
insert a ``url`` field on each of the child items that it returns.
Note that this only works if the child already has enough information
in its fields to build a url (i.e., if the URL for the child
contains an ``:id`` fragment, then the child must have an id
field, which is then used to build its URL.
:param bindargs: A dictionary mapping field names to URL variables.
For example: a child resource may have the URL variable ``:id``,
but have a corresponding field named ``user_id``; the appropriate
value for *bindargs* in this case would be ``{'user_id': 'id'}``.
"""
def __init__(self, of, **args):
super(Collection, self).__init__(**args)
self.include_urls = args.pop('include_urls', False)
bindargs = args.pop('bindargs', {})
self.collects = collections.namedtuple(
"collected_resource", "resource binding")(of, bindargs)
def get_supported_methods(self, model=None):
model = self.compose_model() if model is None else model
supported = super().get_supported_methods(model)
if 'make' in model:
supported.add('POST')
return supported
def _extract_handler(self, request, method, model):
if method == 'POST':
return partial(model['make'], request.input)
else:
return super()._extract_handler(request, method, model)
def handle_request(self, request, wrapper_args):
ret = super().handle_request(request, wrapper_args)
method = request.method.upper()
# After the request has been handled, these branches may modify
# the output
if method == 'POST':
ctx.response.setdefault('status', 201)
url = self._try_build_item_url(ret)
if url is not None:
ctx.response['headers'].setdefault('Location', url)
elif method == 'GET' and self.include_urls:
ret = map(self._include_url_in_item, ret)
return ret
def _include_url_in_item(self, item):
url = self._try_build_item_url(item)
if url is not None:
if isinstance(item, Mapping):
item = dict(item)
item.setdefault('url', url)
else:
try:
item.url = url
except:
pass
return item
def _try_build_item_url(self, data):
child, bind_args = self.collects
if not isinstance(data, Mapping):
data = data.__dict__
args = {(bind_args[k] if k in bind_args else k):data[k]
for k in data}
try:
url = url_adapter.build(child.name, args, append_unknown=False)
except URLBuildError:
pass
else:
return url
__all__ = ['AbstractResource', 'Resource', 'Collection']