Skip to content

Commit

Permalink
Implement a dynamic class registry by extension
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Sep 9, 2016
1 parent d7eee4b commit 62772f2
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 70 deletions.
19 changes: 7 additions & 12 deletions aspen/http/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

from ..exceptions import NegotiationFailure
from ..output import Output
from ..request_processor.dispatcher import NotFound
from ..simplates.simplate import Simplate


class Static(object):
Expand All @@ -28,18 +26,13 @@ def render(self, context):
return Output(body=self.raw, media_type=self.media_type, charset=self.charset)


class Dynamic(Simplate):
class Dynamic(object):
"""Model a dynamic HTTP resource using simplates.
Make .request_processor available as it has been historically.
"""

def __init__(self, request_processor, fs, raw, fs_media_type):
self.request_processor = request_processor
self.fs_media_type = fs_media_type
defaults = request_processor.simplate_defaults
default_media_type = fs_media_type or request_processor.media_type_default
super(Dynamic, self).__init__(defaults, fs, raw, default_media_type)
available_types = [] # populate in your subclass

def render(self, state):
"""Render the resource with the given state as context, return Output.
Expand All @@ -56,6 +49,8 @@ def render(self, state):
Note that we don't always respect the `Accept` header (the spec says
we can ignore it: <https://tools.ietf.org/html/rfc7231#section-5.3.2>).
"""
from ..request_processor.dispatcher import NotFound

available = self.available_types
# When there is an extension in the URI path, the dispatcher gives us the
# corresponding media type (or an empty string if unknown)
Expand All @@ -75,7 +70,7 @@ def render(self, state):
elif len(available) == 1:
# If there's only one available type and no extension in the path,
# then we ignore the Accept header
return super(Dynamic, self).render(available[0], state)
return self.render_for_type(available[0], state)
else:
accept = state.get('accept_header')
if accept:
Expand All @@ -85,11 +80,11 @@ def render(self, state):
# Unparseable accept header
best_match = None
if best_match:
return super(Dynamic, self).render(best_match, state)
return self.render_for_type(best_match, state)
elif best_match == '':
if dispatch_accept is not None:
# e.g. client requested `/foo.json` but `/foo.spt` has no JSON page
raise NotFound()
raise NegotiationFailure(accept, available)
# Fall back to the first available type
return super(Dynamic, self).render(available[0], state)
return self.render_for_type(available[0], state)
34 changes: 22 additions & 12 deletions aspen/request_processor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@
from algorithm import Algorithm

from .typecasting import defaults as default_typecasters
from ..http.resource import Static
from ..configuration import ConfigurationError, configure, parse
from ..simplates.renderers import factories
from ..simplates.simplate import SimplateDefaults


default_indices = lambda: ['index.html', 'index.json', 'index',
Expand Down Expand Up @@ -133,20 +132,23 @@ def safe_getcwd(errorstr):

self.www_root = os.path.realpath(self.www_root)

# load renderers
self.renderer_factories = factories(self)
# kludge simplates -- should move out into a simplate plugin
from ..simplates.renderers import factories
from ..simplates.simplate import Simplate, SimplateDefaults
Simplate.renderer_factories = factories(self)
Simplate.default_renderers_by_media_type = defaultdict(lambda: self.renderer_default)
Simplate.default_renderers_by_media_type[self.media_type_json] = 'json_dump'

self.default_renderers_by_media_type = defaultdict(lambda: self.renderer_default)
self.default_renderers_by_media_type[self.media_type_json] = 'json_dump'

# simplate defaults
initial_context = { 'request_processor': self }
self.simplate_defaults = SimplateDefaults(
self.default_renderers_by_media_type,
self.renderer_factories,
Simplate.defaults = SimplateDefaults(
Simplate.default_renderers_by_media_type,
Simplate.renderer_factories,
initial_context
)

# set up dynamic class mapping
self.dynamic_classes_by_file_extension = dict(spt=Simplate)

# mime.types
# ==========
# It turns out that init'ing mimetypes is somewhat expensive. This is
Expand All @@ -161,4 +163,12 @@ def safe_getcwd(errorstr):
def is_dynamic(self, fspath):
"""Given a filesystem path, return a boolean.
"""
return fspath.endswith('.spt')
return self.get_resource_class(fspath) is not Static


def get_resource_class(self, fspath):
"""Given a filesystem path, return a resource class.
"""
parts = fspath.split('.')
extension = parts[-1] if len(parts) > 1 else None
return self.dynamic_classes_by_file_extension.get(extension, Static)
2 changes: 1 addition & 1 deletion aspen/request_processor/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def dispatch_abstract(listnodes, is_dynamic, is_leaf, traverse, find_index, noex
listnodes(joinedpath) - lists the nodes in the specified joined path
is_dynamic(node) - returns true iff the specified node is a renderable
is_dynamic(node) - returns true iff the specified node is dynamic
is_leaf(node) - returns true iff the specified node is a leaf node
Expand Down
7 changes: 3 additions & 4 deletions aspen/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import traceback

from .exceptions import LoadError
from .http.resource import Dynamic, Static
from .http.resource import Static


__cache__ = dict() # cache, keyed to filesystem path
Expand Down Expand Up @@ -87,7 +87,7 @@ def load(request_processor, fspath, mtime):
"""Given a RequestProcessor, an fspath, and an mtime, return a Resource object (w/o caching).
"""

is_dynamic = request_processor.is_dynamic(fspath)
Class = request_processor.get_resource_class(fspath)

# Load bytes.
# ===========
Expand All @@ -103,7 +103,7 @@ def load(request_processor, fspath, mtime):
# For a negotiated resource we will ignore this.

guess_with = fspath
if is_dynamic:
if Class is not Static:
guess_with = guess_with.rsplit('.', 1)[0]
fs_media_type = mimetypes.guess_type(guess_with, strict=False)[0]
if fs_media_type == 'application/json':
Expand All @@ -113,5 +113,4 @@ def load(request_processor, fspath, mtime):
# ================================
# An instantiated resource is compiled as far as we can take it.

Class = Dynamic if is_dynamic else Static
return Class(request_processor, fspath, raw, fs_media_type)
4 changes: 4 additions & 0 deletions aspen/simplates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
def old_simplate_renderer(fspath):
def renderer(context):
pass
return renderer
17 changes: 9 additions & 8 deletions aspen/simplates/simplate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ..output import Output
from .pagination import split_and_escape, parse_specline, Page
from aspen.http.resource import Dynamic

renderer_re = re.compile(r'[a-z0-9.-_]+$')
media_type_re = re.compile(r'[A-Za-z0-9.+*-]+/[A-Za-z0-9.+*-]+$')
Expand Down Expand Up @@ -75,33 +76,33 @@ def __init__(self, renderers_by_media_type, renderer_factories, initial_context)
self.initial_context = initial_context # type: Dict[str, object]


class Simplate(object):
class Simplate(Dynamic):
"""A simplate is a dynamic resource with multiple syntaxes in one file.
"""

def __init__(self, defaults, fs, raw, default_media_type):
defaults = None # type: SimplateDefaults

def __init__(self, request_processor, fs, raw, fs_media_type):
"""Instantiate a simplate.
defaults - a SimplateDefaults object
fs - path to this simplate
raw - raw content of this simplate as bytes
decoded - content of this simplate as unicode
default_media_type - the default content_type of this simplate
fs_media_type - the filesystem content_type of this simplate
"""

self.defaults = defaults # type: SimplateDefaults
self.request_processor = request_processor
self.fs = fs # type: str
self.raw = raw # type: bytes
self.decoded = _decode(raw) # type: str
self.default_media_type = default_media_type # type: str
self.default_media_type = fs_media_type or request_processor.media_type_default

self.renderers = {} # mapping of media type to Renderer objects
self.available_types = [] # ordered sequence of media types
pages = self.parse_into_pages(self.decoded)
self.pages = self.compile_pages(pages)


def render(self, media_type, context):
def render_for_type(self, media_type, context):
"""
Get the response to a request for this page::
Expand Down
1 change: 1 addition & 0 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def test_user_can_set_renderer_default(harness):
[----]
Greetings, {name}!
"""

harness.request_processor.renderer_default="stdlib_format"
harness.fs.www.mk(('index.html.spt', SIMPLATE),)
actual = harness.simple(filepath=None, uripath='/', want='output.text')
Expand Down
46 changes: 28 additions & 18 deletions tests/test_dynamic_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pytest import raises, yield_fixture

from aspen.exceptions import NegotiationFailure
from aspen.http.resource import Dynamic
from aspen.simplates.simplate import Simplate
from aspen.request_processor.dispatcher import mimetypes, NotFound
from aspen.simplates.pagination import Page
from aspen.simplates.renderers.stdlib_template import Factory as TemplateFactory
Expand All @@ -22,7 +22,7 @@ def get(**_kw):
, fs_media_type = ''
)
kw.update(_kw)
return Dynamic(**kw)
return Simplate(**kw)
yield get


Expand All @@ -31,8 +31,8 @@ def test_dynamic_resource_is_instantiable(harness):
fs = ''
raw = b'[---]\n[---] text/plain via stdlib_template\n'
media_type = ''
actual = Dynamic(request_processor, fs, raw, media_type).__class__
assert actual is Dynamic
actual = Simplate(request_processor, fs, raw, media_type).__class__
assert actual is Simplate


# compile_page
Expand Down Expand Up @@ -85,21 +85,21 @@ def test_parse_specline_enforces_order(get):

def test_parse_specline_obeys_default_by_media_type(get):
resource = get()
resource.request_processor.default_renderers_by_media_type['media/type'] = 'glubber'
resource.default_renderers_by_media_type['media/type'] = 'glubber'
err = raises(ValueError, resource._parse_specline, 'media/type').value
msg = err.args[0]
assert msg.startswith("Unknown renderer for media/type: glubber."), msg

def test_parse_specline_obeys_default_by_media_type_default(get):
resource = get()
resource.request_processor.default_renderers_by_media_type.default_factory = lambda: 'glubber'
resource.default_renderers_by_media_type.default_factory = lambda: 'glubber'
err = raises(ValueError, resource._parse_specline, 'media/type').value
msg = err.args[0]
assert msg.startswith("Unknown renderer for media/type: glubber.")

def test_get_renderer_factory_can_raise_syntax_error(get):
resource = get()
resource.request_processor.default_renderers_by_media_type['media/type'] = 'glubber'
resource.default_renderers_by_media_type['media/type'] = 'glubber'
err = raises( SyntaxError
, resource._get_renderer_factory
, 'media/type'
Expand Down Expand Up @@ -179,21 +179,31 @@ def render_content(self, context):
class GlubberFactory(Factory):
Renderer = Glubber

def install_glubber(harness):
harness.request_processor.renderer_factories['glubber'] = \
GlubberFactory(harness.request_processor)
harness.request_processor.default_renderers_by_media_type['text/plain'] = 'glubber'
class glubber:

def __init__(self, harness):
self.harness = harness

def __enter__(self):
Simplate.renderer_factories['glubber'] = GlubberFactory(self.harness.request_processor)
self.__old = Simplate.default_renderers_by_media_type['text/plain']
Simplate.default_renderers_by_media_type['text/plain'] = 'glubber'

def __exit__(self, *a, **kw):
del Simplate.renderer_factories['glubber']
Simplate.default_renderers_by_media_type['text/plain'] = self.__old


def test_can_override_default_renderers_by_mimetype(harness):
install_glubber(harness)
harness.fs.www.mk(('index.spt', SIMPLATE),)
output = harness.simple(filepath='index.spt', contents=SIMPLATE, accept_header='text/plain')
assert output.text == "glubber"
with glubber(harness):
harness.fs.www.mk(('index.spt', SIMPLATE),)
output = harness.simple(filepath='index.spt', contents=SIMPLATE, accept_header='text/plain')
assert output.text == "glubber"

def test_can_override_default_renderer_entirely(harness):
install_glubber(harness)
output = harness.simple(filepath='index.spt', contents=SIMPLATE, accept_header='text/plain')
assert output.text == "glubber"
with glubber(harness):
output = harness.simple(filepath='index.spt', contents=SIMPLATE, accept_header='text/plain')
assert output.text == "glubber"


# indirect
Expand Down
41 changes: 26 additions & 15 deletions tests/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from aspen.simplates import json_
from aspen.simplates.renderers import Factory, Renderer
from aspen.simplates.simplate import Simplate


def test_a_custom_renderer(harness):
Expand All @@ -28,16 +29,19 @@ def compile_meta(self, configuration):
return 'foobar'

request_processor = harness.request_processor
request_processor.renderer_factories['lorem'] = TestFactory(request_processor)
try:
Simplate.renderer_factories['lorem'] = TestFactory(request_processor)

r = harness.simple("[---]\n[---] text/html via lorem\nLorem ipsum")
assert r.text
d = json_.loads(r.text)
assert d['meta'] == 'foobar'
assert d['raw'] == 'Lorem ipsum'
assert d['media_type'] == 'text/html'
assert d['offset'] == 2
assert d['compiled'] == 'LOREM IPSUM'
r = harness.simple("[---]\n[---] text/html via lorem\nLorem ipsum")
assert r.text
d = json_.loads(r.text)
assert d['meta'] == 'foobar'
assert d['raw'] == 'Lorem ipsum'
assert d['media_type'] == 'text/html'
assert d['offset'] == 2
assert d['compiled'] == 'LOREM IPSUM'
finally:
del Simplate.renderer_factories['lorem']


def test_renderer_padding_works_with_padded_output(harness):
Expand All @@ -54,10 +58,13 @@ class TestFactory(Factory):
Renderer = TestRenderer

request_processor = harness.request_processor
request_processor.renderer_factories['x'] = TestFactory(request_processor)
try:
Simplate.renderer_factories['x'] = TestFactory(request_processor)

output = harness.simple("[---]\n[---] text/plain via x\nSome text")
assert output.text == '\nSome text\n'
output = harness.simple("[---]\n[---] text/plain via x\nSome text")
assert output.text == '\nSome text\n'
finally:
del Simplate.renderer_factories['x']


def test_renderer_padding_works_with_stripped_output(harness):
Expand All @@ -74,10 +81,14 @@ class TestFactory(Factory):
Renderer = TestRenderer

request_processor = harness.request_processor
request_processor.renderer_factories['y'] = TestFactory(request_processor)
try:
Simplate.renderer_factories['y'] = TestFactory(request_processor)

output = harness.simple("[---]\n[---] text/plain via y\nSome text")
assert output.text == '\nSome text\n'
finally:
del Simplate.renderer_factories['y']

output = harness.simple("[---]\n[---] text/plain via y\nSome text")
assert output.text == '\nSome text\n'


def test_renderer_padding_achieves_correct_line_numbers_in_tracebacks(harness):
Expand Down

0 comments on commit 62772f2

Please sign in to comment.