diff --git a/test/test_core/test_context.py b/test/test_core/test_context.py index d88b17d6..5e5d8019 100644 --- a/test/test_core/test_context.py +++ b/test/test_core/test_context.py @@ -61,16 +61,24 @@ def test_context_group_initial_arguments(): def test_context_group_default(): - inner = ContextGroup() + inner = Context() group = ContextGroup(inner) - thing = group.foo = Thing() + thing = Thing() + + # Propagation from inner to group: + inner.foo = thing assert inner.foo is thing - assert group.foo is thing - del group.foo - assert 'foo' not in inner, list(inner) - assert 'foo' not in group, group.foo - - + del inner.foo + assert 'foo' not in inner + assert 'foo' not in group + + # Propagation from group to default inner: + group.bar = thing + assert inner.bar is thing + + del group.bar + assert 'bar' not in inner + assert 'bar' not in group diff --git a/test/test_extension/test_base.py b/test/test_extension/test_base.py index b5682f4e..5a4b294d 100644 --- a/test/test_extension/test_base.py +++ b/test/test_extension/test_base.py @@ -74,7 +74,7 @@ def test_response(self): assert self.do(response_endpoint).text == "Yo." def test_file(self): - assert '2016' in self.do(binary_file_endpoint).text + assert '2006' in self.do(binary_file_endpoint).text def test_generator(self): assert 'foobar' in self.do(generator_endpoint).text diff --git a/test/test_extension/test_handler.py b/test/test_extension/test_handler.py new file mode 100644 index 00000000..e8d6ce45 --- /dev/null +++ b/test/test_extension/test_handler.py @@ -0,0 +1,40 @@ +import pytest +from webob import Request +from webob.exc import HTTPNotFound + +from web.core import Application +from web.core.testing import MockRequest +from web.core.ext.handler import StatusHandlers + + +def mock_endpoint(context, status, state=None): + context.response.status_int = int(status) + mock_endpoint.state = state + return state + + +@pytest.fixture +def app(): + return Application(mock_endpoint, [StatusHandlers({ + HTTPNotFound: '/404/notfound', + 503: '/503/maintenance', + })]) + + +def test_notfound(app): + with MockRequest('/404') as request: + response = request.send(app) + + assert response.text == 'notfound' + assert response.status_int == 404 + assert mock_endpoint.state == 'notfound' + + +def test_maintenance(app): + with MockRequest('/404') as request: + response = request.send(app) + + assert response.text == 'notfound' + assert response.status_int == 404 + assert mock_endpoint.state == 'notfound' + diff --git a/web/core/application.py b/web/core/application.py index 92389098..ef14fb92 100644 --- a/web/core/application.py +++ b/web/core/application.py @@ -237,7 +237,9 @@ def application(self, environ, start_response): try: result = self._execute_endpoint(context, handler, signals) # Process the endpoint. except Exception as e: - log.exception("Caught exception attempting to execute the endpoint.") + log.error("Caught exception attempting to execute the endpoint.", exc_info=True) + raise + result = HTTPInternalServerError(str(e) if __debug__ else "Please see the logs.") if 'debugger' in context.extension.feature: diff --git a/web/core/context.py b/web/core/context.py index e26dc8b4..5fb09504 100644 --- a/web/core/context.py +++ b/web/core/context.py @@ -96,7 +96,7 @@ class ContextGroup(Context): default = None def __init__(self, default=None, **kw): - if default: + if default is not None: self.__dict__['default'] = default # Avoid attribute assignment protocol for this one. for name in kw: diff --git a/web/core/dispatch.py b/web/core/dispatch.py index 5f21c0c7..aacc13bd 100644 --- a/web/core/dispatch.py +++ b/web/core/dispatch.py @@ -1,22 +1,11 @@ -# encoding: utf-8 - -# ## Imports - -from __future__ import unicode_literals - from collections import deque from inspect import isclass from marrow.package.host import PluginManager -# ## Module Globals - -# A standard logger object. -log = __import__('logging').getLogger(__name__) +log = __import__('logging').getLogger(__name__) # A standard logger object. -# ## Dispatch Plugin Manager - class WebDispatchers(PluginManager): """WebCore dispatch protocol adapter. @@ -43,7 +32,7 @@ def __init__(self, ctx): an attribute of the current Application or Request context: `context.dispatch` """ - super(WebDispatchers, self).__init__('web.dispatch') + super().__init__('web.dispatch') def __call__(self, context, handler, path): """Having been bound to an appropriate context, find a handler for the request path. @@ -85,8 +74,7 @@ def __call__(self, context, handler, path): if is_endpoint and not callable(handler) and hasattr(handler, '__dispatch__'): crumb = crumb.replace(endpoint=False) - - #__import__('wdb').set_trace() + # DO NOT add production logging statements (ones not wrapped in `if __debug__`) to this callback! for ext in callbacks: ext(context, str(crumb.path) if crumb.path else None, crumb.handler, crumb.endpoint) diff --git a/web/core/testing.py b/web/core/testing.py new file mode 100644 index 00000000..dc37df6a --- /dev/null +++ b/web/core/testing.py @@ -0,0 +1,110 @@ +from warnings import warn +from typing import Any, Optional, Mapping + +from uri import URI, Bucket +from web.auth import authenticate, deauthenticate +from web.core import local +from webob import Request, Response +from webob.exc import HTTPException, HTTPOk + + +''' WIP +from pytest import fixture + + +@fixture(scope='session', autouse=True) +def testing_context(application): + """Provide and manage the TestingContext Pytest must execute within. + + Your own test suite must define a fixture named `application`, which will be utilized by this fixture to populate + the context and signal any configured extensions. + """ + + original = local.context + signals = original.extension.signal + + # Prepare a TestingContext using a blank request. + request = Request.blank('/') + context = app.RequestContext(environ=request.environ)._promote('TestingContext') + + # notify suite began + + yield context + + # notify suite finished + + for ext in signals.stop: ext(original) + + local.context = original + + +@fixture(scope='function', autouse=True) +def test_context(testing_context): + for ext in signals.pre: ext(testing_context) + for ext in signals.test: ext(testing_context) + + yield context + + for ext in signals.after: ext(testing_context) + for ext in signals.done: ext(testing_context) +''' + + +class MockRequest: + """Prepare a new, mocked request. + + Must be passed the current context and the path to an endpoint to execute. + + The `verb` defaults to "get". Any `data` passed in will be encoded as the request body. Additional keyword + arguments are treated as query string parameters. These will be added to the existing set, they will not override + existing values present in the base endpoint URI. + """ + + __slots__ = ('url', 'verb', 'data', 'user') + + uri: URI + verb: str + data: Optional[Mapping] + user: Any + + def __init__(self, verb:str='GET', path:str='', data:Optional[Mapping]=None, user:Optional[str]=None, /, **kw): + """Construct the components necessary to request a given endpoint directly from a WSGI application. + + Form-encoded body data is specified by way of the `data` argument; keyword arguments are added to the endpoint + URI as query string arguments. Active user mocking exposes a `web.account` WSGI environment variable. + """ + + self.uri = URI("http://localhost/") / path + self.verb = verb + self.data = data + self.user = user + + for k, v in kw.items(): + self.uri.query.buckets.append(Bucket(k ,v)) + + def __enter__(self): + """Populate and hand back a populated mock WebOb Request object, ready to be invoked.""" + req = Request.blank(str(self.uri), POST=self.data, environ={'REQUEST_METHOD': self.verb}) + if self.user: req.environ['web.account'] = self.user + return req + + @classmethod + def send(MockRequest, app:WSGI, verb:str='GET', path:str='', data:Optional[Mapping]=None, user:Optional[str]=None, + expect:Optional[HTTPException]=None) -> Response: + """Populate, then invoke a populated WebOb Request instance against the given application. + + This returns the resulting WebOb Response instance. + """ + + if expect is None: expect = HTTPOk + location = None + + with MockRequest(verb, path, user=user, **((data or {}) if verb == 'GET' else {'data': data})) as request: + response = request.send(app) + + if isinstance(expect, tuple): expect, location = expect + assert response.status_int == expect.code + if location: assert response.location == 'http://localhost:8080' + location + + return response + diff --git a/web/core/typing.py b/web/core/typing.py new file mode 100644 index 00000000..1964e868 --- /dev/null +++ b/web/core/typing.py @@ -0,0 +1,81 @@ +"""Typing helpers.""" + +from logging import Logger +from pathlib import Path, PurePosixPath +from types import ModuleType +from typing import Any, Callable, ClassVar, Dict, Generator, Iterable, List, Mapping, Optional, Set, Tuple, Union, \ + Text, Type, Pattern, MutableSet + +from typeguard import check_argument_types +from uri import URI +from webob import Request, Response + +from ..dispatch.core import Crumb +from .context import Context # Make abstract? :'( + + +# Core application configuration components. + +AccelRedirectSourcePrefix = Union[str, Path] +AccelRedirectSourceTarget = PathLike = Union[str, PurePosixPath, URI] +AccelRedirect = Optional[Tuple[AccelRedirectSourcePrefix, AccelRedirectSourceTarget]] + + +# Types for WebCore extension component parts. + +Tags = Set[str] # Extension feature and dependency tags. +PositionalArgs = List[Any] # Positional arguments to the endpoint callable. +KeywordArgs = Dict[str, Any] # Keyword arguments to the endpoint callable. +Environment = Dict[str, Any] # An interactive shell REPL environment. +StatusLike = Union[str, int, Response, HTTPError] # Objects usable as an HTTP status. + + +# Types for WSGI component parts. + +# Passed to the WSGI application. +WSGIEnvironment = Dict[Text, Any] + +# Passed to start_response. +WSGIStatus = str +WSGIHeaders = List[Tuple[str, str]] +WSGIException = Optional[Tuple[Any, Any, Any]] + +# Returned by start_response. +WSGIWriter = Callable[[bytes], None] + +WSGIResponse = Union[ + Generator[bytes, None, None], + Iterable[bytes] + ] + +# Types for core WSGI protocol components. + +# Passed to the WSGI application. +WSGIStartResponse = Callable[[WSGIStatus, WSGIHeaders, WSGIException], WSGIWriter] + +# WSGI application object itself. +WSGI = Callable[[WSGIEnvironment, WSGIStartResponse], WSGIResponse] + + +# Types relating to specific forms of callback utilized by the framework. + +# The `serve` web application/server bridge API interface. +HostBind = str +PortBind = int +DomainBind = Optional[Union[str,Path]] +WebServer = Callable[..., None] # [WSGI, HostBind, PortBind, ...] + +# Endpoint return value handlers. +View = Callable[[Context,Any],bool] + +# Serialization extension related typing. +SerializationTypes = Iterable[type] +Serializer = Callable[[Any], str] +Deserializer = Callable[[str], Any] + + +# Specific utility forms. + +PatternString = Union[str, Pattern] +PatternStrings = Iterable[PatternString] + diff --git a/web/ext/analytics.py b/web/ext/analytics.py index 66f9618e..80caddbd 100644 --- a/web/ext/analytics.py +++ b/web/ext/analytics.py @@ -63,7 +63,7 @@ def after(self, context, exc=None): context.response.headers[self.header] = delta if self.log: - self.log("Response generated in " + delta + " seconds.", extra=dict( + self.log("Response generated in " + delta + " milliseconds.", extra=dict( duration = duration, request = id(context) )) diff --git a/web/ext/asynchronous.py b/web/ext/asynchronous.py new file mode 100644 index 00000000..5593de5f --- /dev/null +++ b/web/ext/asynchronous.py @@ -0,0 +1,94 @@ +"""Asynchronous support machinery and extension for WebCore. + +As WebCore is not yet asynchronous internally, some effort needs to be expended to correctly interoperate. Primarily, +an event loop needs to be executing in order to process asynchronous invocations and awaiting behaviour. WebCore +accomplishes this by-if no existing loop is running-spawning one in a dedicated thread. + +Do not mix this asynchronous extension with one providing futures thread- or process-based parallelism, as the order +the extensions are defined would determine which succeeds in providing the `submit` context method. Both futures and +async extensions provide this method in order to submit background tasks for execution. To be explicit, utilize +`context.loop` for asynchronous interactions, and `context.executor` for futures-based interactions. + +To utilize, invoke an asynchronous function to acquire a coroutine handle and pass that handle to `context.submit`, +scheduling execution within the main thread with active event loop, or a dedicated thread for the asynchronous reactor +event loop. E.g.: + + async def hello(name): return f"Hello {name}!" + future = self._ctx.submit(hello("world")) + future.result() + + future = context.submit(hello("world")) + future.add_done_callback(...) # Execute a callback upon completion of the asynchronous task. + future.result() # Will block the worker thread on the result of asynchronous execution within the event loop. +""" + +import sys + +from asyncio import AbstractEventLoop, get_event_loop, new_event_loop, set_event_loop, run_coroutine_threadsafe +from functools import partial +from threading import Thread +from typing import Optional + +from web.core import local +from web.core.context import Context + + +log = __import__('logging').getLogger('rita.util.async') # __name__) + + +class AsynchronousSupport: + """Support for asynchronous language functionality. + + Accepts a single configuration argument, `loop`, allowing you to explicitly define an event loop to utilize rather + than relying upon default `new_event_loop` behaviour. + """ + + provides: set = {'async'} + + loop: AbstractEventLoop # The asynchronous event loop to utilize. + thread: Optional[Thread] = None # Offload asynchronous execution to this thread, if needed. + + def __init__(self, loop:Optional[AbstractEventLoop]=None): + """Prepare an asynchronous reactor / event loop for use, which may optionally be explicitly specified.""" + + if not loop: loop = get_event_loop() + if not loop: loop = new_event_loop() + self.loop = loop + + def start(self, context:Context): + """Executed on application startup.""" + + if not self.loop.is_running(): # Thread of execution for asynchronous code if required. + self.thread = Thread(target=self.thread, args=(context, self.loop), daemon=True, name='async') + self.thread.start() # With no outer async executor, spawn our own in a dedicated thread. + + # Expose our event loop at the application scope. + context.loop = self.loop + + log.debug(f"Asynchronous event loop / reactor: {self.loop!r}") + + context.submit = staticmethod(partial(run_coroutine_threadsafe, loop=self.loop)) + + def prepare(self, context:Context): + """Explicitly define the running asynchronous loop within our request worker thread.""" + + set_event_loop(self.loop) + + # Global bind may be sufficient. + # context.submit = partial(run_coroutine_threadsafe, loop=self.loop) + + def stop(self, context:Context): + """On application shutdown, tell the executor to stop itself. + + The worker thread, if utilized, is marked 'daemon' and stops with application process or when the reactor is + shut down. + """ + + self.loop.call_soon_threadsafe(self.loop.stop) + + def thread(self, context:Context, loop:AbstractEventLoop): + log.warn("Asynchronous event loop / reactor thread spawned.") + local.context = context + loop.run_forever() + log.warn("Asynchronous event loop / reactor shut down.") + diff --git a/web/ext/handler.py b/web/ext/handler.py new file mode 100644 index 00000000..4587e454 --- /dev/null +++ b/web/ext/handler.py @@ -0,0 +1,142 @@ +"""Permit the specification of internal routes to utilize to replace responses having specific statuses. + +This is typically used to implement error page responses using internal redirection. The initially returned status +code is utilized for the ultimate response, so no need to worry that your error page handlers also utilize the error +status. + +Example usage, imperative configuration as WSGI middleware: (ignoring that this is a WebCore extension) + + ext = StatusHandlers() + ext[404] = '/fourohfour' + ext[HTTPInternalServerError] = '/died' + app = ext(None, app) # This extension does not utilize the first argument, an application context. + +Immediate declarative, rather than imperative configuration: + + ext = StatusHandlers({ + 404: '/fourohfour', + HTTPInternalServerError: '/died', + }) + +""" + +from os import environ, getenv as env +from typing import Mapping, Optional, Union +from warnings import warn + +from typeguard import typechecked +from webob import Request +from webob.exc import HTTPError + +from web.core.typing import Context, PathLike, StatusLike, \ + WSGI, WSGIEnvironment, WSGIStartResponse, WSGIStatus, WSGIHeaders, WSGIException, WSGIWriter + + +class StatusHandlers: + handlers: Mapping[int, str] + + def __init__(self, handlers:Optional[Mapping[StatusLike, PathLike]]=None): + self.handlers = {self._normalize(k): str(v) for k, v in handlers.items()} if handlers else {} + + @typechecked + def _normalize(self, status:Union[str, StatusLike]) -> int: + if isinstance(status, str): status = int(status.partition(' ')[0]))) # Handle strings like "404 Not Found". + else: status = getattr(status, 'status_int', getattr(status, 'code', status)) # Process everything else. + + if not isinstance(status, int): + raise TypeError(f"Status must be an integer, integer-prefixed string, Response-, or HTTPException-compatible type, not: {status!r} ({type(status)})") + + return status + + @property + @typechecked + def _maintenance(self) -> bool: + """Identify if the application is running in "maintenance mode". + + "Maintenance mode" will always show the result of the 503 error handler. + """ + + # Pull the maintenance status from the application environment, by default, excluding local development. + return bool(env('MAINTENANCE', False)) and not __debug__ + + # Proxy dictionary-like access through to the underlying status code mapping, normalizing on integer keys. + # These primarily exist to perform automatic extraction of the integer status code from HTTPError subclasses. + + @typechecked + def __contains__(self, status:StatusLike) -> bool: + """Determine if an internal redirection has been specified for the given status.""" + status = self._normalize(status) + return status in self.handlers + + @typechecked + def __getitem__(self, status:StatusLike) -> str: + """Retrieve the path specified for handling a specific status code or HTTPException.""" + return self.handlers[status] + + @typechecked + def __setitem__(self, status:StatusLike, handler:PathLike) -> None: + """Assign a new handler path for the given status code or HTTPException.""" + status = self._normalize(status) + self.handlers[status] = str(handler) + + @typechecked + def __delitem__(self, status:StatusLike) -> None: + """Remove a handler for the specified status code or HTTPException.""" + status = self._normalize(status) + del self.handlers[status] + + @typechecked + def __len__(self) -> int: + """Return the count of defined status handlers.""" + return len(self.handlers) + + # WebCore extension WSGI middleware hook. + + @typechecked + def __call__(self, context:Context, app:WSGI) -> WSGI: + """Decorate the WebCore application object with middleware to interpose and internally redirect by status. + + This can be directly invoked to wrap any WSGI application, even non-WebCore ones. + """ + + @typechecked + def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSGIResponse: + """Interposing WSGI middleware to capture start_response and internally redirect if needed.""" + + capture = [] + _maintenance: bool = self._maintenance # Calculate this only once. + + if _maintenance and 503 in self.handlers: + request = Request.blank(self.handlers[503]) + result = request.send(app, catch_exc_info=True) + start_response(b'503 Service Unavailable', result.headerlist) + return result.app_iter + + elif _maintenance: + warn("Maintenance mode enabled with no 503 handler available.", RuntimeWarning) + + @typechecked + def local_start_response(status:WSGIStatus, headers:WSGIHeaders, exc_info:WSGIException=None) -> WSGIWriter: + """Capture the arguments to start_response, forwarding if not configured to internally redirect.""" + + status = status if isinstance(status, str) else status.decode('ascii') + status_code = int(status.partition(' ')[0]) + + if status_code not in self.handlers: + return start_response(status, headers) + + capture.extend((status, headers, exc_info, self.handlers.get(status_code, None))) + + # TODO: How to handle WSGIWriter return in the "not captured" case? + + result = app(environ, local_start_response) + if not capture or not capture[-1]: return result + + request = Request.blank(capture[-1]) + result = request.send(app, catch_exc_info=True) + + start_response(capture[0], result.headerlist) + return result.app_iter + + return middleware +