From 4e3a018ccecccdfa7c99ab14e3d9f823e006c8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 24 Nov 2022 11:01:22 -0500 Subject: [PATCH 01/35] Version pin adjustment. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1af5b8c2..9579ad47 100755 --- a/setup.py +++ b/setup.py @@ -103,7 +103,7 @@ install_requires = [ 'marrow.package~=2.0.0', # dynamic execution and plugin management - 'web.dispatch>=' + 'web.dispatch~=3.0.1', # endpoint discovery 'WebOb', # HTTP request and response objects, and HTTP status code exceptions ], From 0d68aa60cfcd8b0ba8fd18b201fcf1fb77587278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 29 Nov 2022 09:56:24 -0500 Subject: [PATCH 02/35] Pin greater than. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 9579ad47..1de8a894 100755 --- a/setup.py +++ b/setup.py @@ -102,8 +102,8 @@ ] if {'pytest', 'test', 'ptr'}.intersection(argv) else [], install_requires = [ - 'marrow.package~=2.0.0', # dynamic execution and plugin management - 'web.dispatch~=3.0.1', # endpoint discovery + 'marrow.package>=2.0.0', # dynamic execution and plugin management + 'web.dispatch>=3.0.1', # endpoint discovery 'WebOb', # HTTP request and response objects, and HTTP status code exceptions ], From fc6f09c5ff224a9a2ad1ab69f65edc9b8dda11b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 6 Dec 2022 08:31:40 -0500 Subject: [PATCH 03/35] Adapt (older extensions) to modern dispatch protocol. --- web/core/dispatch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/core/dispatch.py b/web/core/dispatch.py index 68b503a3..5f21c0c7 100644 --- a/web/core/dispatch.py +++ b/web/core/dispatch.py @@ -80,11 +80,15 @@ def __call__(self, context, handler, path): starting = handler # Iterate dispatch events, issuing appropriate callbacks as we descend. - for consumed, handler, is_endpoint in dispatcher(context, handler, path): + for crumb in dispatcher(context, handler, path): + is_endpoint, handler = crumb.endpoint, crumb.handler + if is_endpoint and not callable(handler) and hasattr(handler, '__dispatch__'): - is_endpoint = False + 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, consumed, handler, is_endpoint) + for ext in callbacks: ext(context, str(crumb.path) if crumb.path else None, crumb.handler, crumb.endpoint) # Repeat of earlier, we do this after extensions in case anything above modifies the environ path. path = context.environ['PATH_INFO'].strip('/') From 9fffbad3731d2da8c74f23f066c172ce285c5e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 13 Dec 2022 12:47:02 -0500 Subject: [PATCH 04/35] Minimum Python version bump. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1de8a894..ec35d236 100755 --- a/setup.py +++ b/setup.py @@ -95,7 +95,7 @@ package_data = {'': ['README.rst', 'LICENSE.txt']}, zip_safe = False, - python_requires = '~=3.6', + python_requires = '>=3.8', setup_requires = [ 'pytest-runner', From 28da0ef7ff7c7356df7804831848009666c811ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 13 Dec 2022 12:47:16 -0500 Subject: [PATCH 05/35] collections->typing module movement. --- web/core/context.py | 4 +++- web/ext/serialize.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/core/context.py b/web/core/context.py index 5e7d916b..1fb9449d 100644 --- a/web/core/context.py +++ b/web/core/context.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals -from collections import MutableMapping +from typing import MutableMapping # ## Mapping Class @@ -99,6 +99,8 @@ def __init__(self, default=None, **kw): if default is not None: self.default = default default.__name__ = 'default' + else: + self.default = default for name in kw: kw[name].__name__ = name diff --git a/web/ext/serialize.py b/web/ext/serialize.py index 5ad768d2..5b9605b9 100644 --- a/web/ext/serialize.py +++ b/web/ext/serialize.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals import pkg_resources -from collections import Mapping +from typing import Mapping from marrow.package.host import PluginManager from web.core.compat import str From e33ce1e5df23e780a11a14fcbe5ed55f5e70b998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 15 Dec 2022 10:40:27 -0500 Subject: [PATCH 06/35] Protect ContextGroup against "protected" access, better handle default ContextGroup proxy. --- web/core/context.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/core/context.py b/web/core/context.py index 1fb9449d..e26dc8b4 100644 --- a/web/core/context.py +++ b/web/core/context.py @@ -96,11 +96,8 @@ class ContextGroup(Context): default = None def __init__(self, default=None, **kw): - if default is not None: - self.default = default - default.__name__ = 'default' - else: - self.default = default + if default: + self.__dict__['default'] = default # Avoid attribute assignment protocol for this one. for name in kw: kw[name].__name__ = name @@ -130,7 +127,7 @@ def __delitem__(self, name): del self.__dict__[name] def __getattr__(self, name): - if self.default is None: + if self.default is None or name.startswith('_'): raise AttributeError() return getattr(self.default, name) From fa7ab39915f76db700ac36c79bbb41249be58e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 12 Jan 2023 13:56:58 -0500 Subject: [PATCH 07/35] Cleanup. --- web/core/dispatch.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/web/core/dispatch.py b/web/core/dispatch.py index 5f21c0c7..13f717bd 100644 --- a/web/core/dispatch.py +++ b/web/core/dispatch.py @@ -1,21 +1,10 @@ -# 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 +log = __import__('logging').getLogger(__name__) # A standard logger object. -# A standard logger object. -log = __import__('logging').getLogger(__name__) - - -# ## 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. @@ -83,10 +72,6 @@ def __call__(self, context, handler, path): for crumb in dispatcher(context, handler, path): is_endpoint, handler = crumb.endpoint, crumb.handler - 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) From ce3bb462621fdff541e93529f19dfb890fc1cc2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 14 Feb 2023 10:15:27 -0500 Subject: [PATCH 08/35] Correct units. --- web/ext/analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) )) From d4fe10872079635b9e35dee726f36dcde8d367f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 1 Jun 2023 11:10:35 -0400 Subject: [PATCH 09/35] My kingdom for an identity check against None, not a truthy one. --- web/core/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 3ac86b9dc19f48f1c716bb5fde3edfe386660871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 1 Jun 2023 11:10:57 -0400 Subject: [PATCH 10/35] Copyright test bump (wat) and actually testing ContextGroup as intended. --- test/test_core/test_context.py | 10 ++++------ test/test_extension/test_base.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/test_core/test_context.py b/test/test_core/test_context.py index d88b17d6..dba3efaf 100644 --- a/test/test_core/test_context.py +++ b/test/test_core/test_context.py @@ -61,16 +61,14 @@ def test_context_group_initial_arguments(): def test_context_group_default(): - inner = ContextGroup() - group = ContextGroup(inner) + inner = Context() + group = ContextGroup(default=inner) - thing = group.foo = Thing() + thing = 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 - - + assert 'foo' not in group, 'foo remains 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 From 02ac01a338efda40497209b50e2b3e093f901874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 1 Jun 2023 11:25:23 -0400 Subject: [PATCH 11/35] Actually working tests. --- test/test_core/test_context.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/test_core/test_context.py b/test/test_core/test_context.py index dba3efaf..5e5d8019 100644 --- a/test/test_core/test_context.py +++ b/test/test_core/test_context.py @@ -62,13 +62,23 @@ def test_context_group_initial_arguments(): def test_context_group_default(): inner = Context() - group = ContextGroup(default=inner) + group = ContextGroup(inner) - thing = inner.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, 'foo remains in group' + 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 From 0366da9c083f293cb9a94dbca68aa7b73649253c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 10 Aug 2023 15:58:39 -0400 Subject: [PATCH 12/35] Add MockRequest and validate testing helpers. --- web/core/testing.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 web/core/testing.py diff --git a/web/core/testing.py b/web/core/testing.py new file mode 100644 index 00000000..12fa88dd --- /dev/null +++ b/web/core/testing.py @@ -0,0 +1,58 @@ +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 HTTPOk + + + +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='GET', endpoint='', data=None, user=None, **kw): + self.uri = URI("http://localhost:8080/") / endpoint + 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): + 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 + + +def validate(app, verb, path, data=None, user=None, expect=None): + if expect is None: expect = HTTPOk + location = None + + print(f"Issuing {verb} request to {path} as {user}, expecting: {expect}") + + 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 + From f449f5d58e0d05e5b93f2ba19f70d8c5ffaade6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 22 Aug 2023 15:57:33 -0400 Subject: [PATCH 13/35] More explicitly log uncaught exceptions as errors, explicitly including exception information. --- web/core/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/application.py b/web/core/application.py index 92389098..658e085f 100644 --- a/web/core/application.py +++ b/web/core/application.py @@ -237,7 +237,7 @@ 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) result = HTTPInternalServerError(str(e) if __debug__ else "Please see the logs.") if 'debugger' in context.extension.feature: From d8dbbadd0d9d599ea53257f4dc1c7a355547d503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 24 Aug 2023 11:35:51 -0400 Subject: [PATCH 14/35] Desperation. --- web/core/application.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/core/application.py b/web/core/application.py index 658e085f..ef14fb92 100644 --- a/web/core/application.py +++ b/web/core/application.py @@ -238,6 +238,8 @@ def application(self, environ, start_response): result = self._execute_endpoint(context, handler, signals) # Process the endpoint. except Exception as e: 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: From e1eed88cfca4c0048ef3eb74f148ad99e020fc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 09:53:11 -0400 Subject: [PATCH 15/35] Add Python 3 type hinting annotation helpers and status code-based internal redirection extension. --- web/core/typing.py | 78 ++++++++++++++++++++++++++++++++ web/ext/handler.py | 108 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 web/core/typing.py create mode 100644 web/ext/handler.py diff --git a/web/core/typing.py b/web/core/typing.py new file mode 100644 index 00000000..580addb6 --- /dev/null +++ b/web/core/typing.py @@ -0,0 +1,78 @@ +"""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 = 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. + + +# 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/handler.py b/web/ext/handler.py new file mode 100644 index 00000000..16b0864e --- /dev/null +++ b/web/ext/handler.py @@ -0,0 +1,108 @@ +"""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 urllib.parse import unquote_plus +from typing import Mapping, Optional, Union + +from webob import Request, Response +from webob.exc import HTTPError + +from web.core.typing import Context, \ + WSGI, WSGIEnvironment, WSGIStartResponse, WSGIStatus, WSGIHeaders, WSGIException, WSGIWriter + + +StatusLike = Union[int, Response, HTTPError] + + +class StatusHandlers: + handlers: Mapping[int, str] + + def __init__(self, handlers:Optional[Mapping[int, str]]=None): + self.handlers = handlers or {} + + def __normalize(self, status:StatusLike) -> int: + status = getattr(status, 'status_int', getattr(status, 'code', status)) + if not isinstance(status, int): raise TypeError("HTTP status code must be an integer, Response-, or HTTPException-compatible type.") + return status + + @property + 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. + + def __getitem__(self, status:StatusLike) -> str: + """Retrieve the path specified for handling a specific status code or HTTPException.""" + return self.handlers[status] + + def __setitem__(self, status:StatusLike, handler:str) -> None: + """Assign a new handler path for the given status code or HTTPException.""" + status = self.__normalize(status) + self.handlers[status] = handler + + def __delitem__(self, status:StatusLike) -> None: + """Remove a handler for the specified status code or HTTPException.""" + status = self.__normalize(status) + del self.handlers[status] + + # WebCore extension WSGI middleware hook. + + def __call__(self, context:Context, app:WSGI) -> WSGI: + """Decorate the WebCore application object with middleware to interpose and internally redirect by status.""" + + def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSGIResponse: + capture = [] + + if self._maintenance: + 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 + + def local_start_response(status:WSGIStatus, headers:WSGIHeaders, exc_info:WSGIException=None) -> WSGIWriter: + 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))) + + result = app(environ, local_start_response) + + if not capture: 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 + From c5013ee84ff77a7ea6c1af069468a8b507a261e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 09:58:13 -0400 Subject: [PATCH 16/35] Docstrings, and easier customization of normalization. --- web/ext/handler.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index 16b0864e..555d7b26 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -40,7 +40,7 @@ class StatusHandlers: def __init__(self, handlers:Optional[Mapping[int, str]]=None): self.handlers = handlers or {} - def __normalize(self, status:StatusLike) -> int: + def _normalize(self, status:StatusLike) -> int: status = getattr(status, 'status_int', getattr(status, 'code', status)) if not isinstance(status, int): raise TypeError("HTTP status code must be an integer, Response-, or HTTPException-compatible type.") return status @@ -63,12 +63,12 @@ def __getitem__(self, status:StatusLike) -> str: def __setitem__(self, status:StatusLike, handler:str) -> None: """Assign a new handler path for the given status code or HTTPException.""" - status = self.__normalize(status) + status = self._normalize(status) self.handlers[status] = handler def __delitem__(self, status:StatusLike) -> None: """Remove a handler for the specified status code or HTTPException.""" - status = self.__normalize(status) + status = self._normalize(status) del self.handlers[status] # WebCore extension WSGI middleware hook. @@ -77,6 +77,8 @@ def __call__(self, context:Context, app:WSGI) -> WSGI: """Decorate the WebCore application object with middleware to interpose and internally redirect by status.""" def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSGIResponse: + """Interposing WSGI middleware to capture start_response and internally redirect if needed.""" + capture = [] if self._maintenance: @@ -86,6 +88,8 @@ def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSG return result.app_iter 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]) @@ -95,7 +99,6 @@ def local_start_response(status:WSGIStatus, headers:WSGIHeaders, exc_info:WSGIEx capture.extend((status, headers, exc_info, self.handlers.get(status_code, None))) result = app(environ, local_start_response) - if not capture: return result request = Request.blank(capture[-1]) From 8d4fec9528d3d5b79fab20fa5a6c8ba9c625d2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:01:59 -0400 Subject: [PATCH 17/35] Additional annotations. --- web/core/typing.py | 3 ++- web/ext/handler.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/core/typing.py b/web/core/typing.py index 580addb6..9bae1438 100644 --- a/web/core/typing.py +++ b/web/core/typing.py @@ -16,7 +16,7 @@ # Core application configuration components. AccelRedirectSourcePrefix = Union[str, Path] -AccelRedirectSourceTarget = Union[str, PurePosixPath, URI] +AccelRedirectSourceTarget = PathLike = Union[str, PurePosixPath, URI] AccelRedirect = Optional[Tuple[AccelRedirectSourcePrefix, AccelRedirectSourceTarget]] @@ -76,3 +76,4 @@ PatternString = Union[str, Pattern] PatternStrings = Iterable[PatternString] + diff --git a/web/ext/handler.py b/web/ext/handler.py index 555d7b26..28491430 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -27,7 +27,7 @@ from webob import Request, Response from webob.exc import HTTPError -from web.core.typing import Context, \ +from web.core.typing import Context, PathLike, \ WSGI, WSGIEnvironment, WSGIStartResponse, WSGIStatus, WSGIHeaders, WSGIException, WSGIWriter @@ -61,10 +61,10 @@ def __getitem__(self, status:StatusLike) -> str: """Retrieve the path specified for handling a specific status code or HTTPException.""" return self.handlers[status] - def __setitem__(self, status:StatusLike, handler:str) -> None: + 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] = handler + self.handlers[status] = str(handler) def __delitem__(self, status:StatusLike) -> None: """Remove a handler for the specified status code or HTTPException.""" From d5a79c21c8829ec281d370c10748f51b5544ec1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:33:10 -0400 Subject: [PATCH 18/35] Explicit typechecking and shuffling of type definitions. --- web/core/typing.py | 2 ++ web/ext/handler.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/core/typing.py b/web/core/typing.py index 9bae1438..c6c5c214 100644 --- a/web/core/typing.py +++ b/web/core/typing.py @@ -13,6 +13,7 @@ from ..dispatch.core import Crumb from .context import Context # Make abstract? :'( + # Core application configuration components. AccelRedirectSourcePrefix = Union[str, Path] @@ -26,6 +27,7 @@ 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[int, Response, HTTPError] # Objects usable as an HTTP status. # Types for WSGI component parts. diff --git a/web/ext/handler.py b/web/ext/handler.py index 28491430..f2da895d 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -24,28 +24,28 @@ from urllib.parse import unquote_plus from typing import Mapping, Optional, Union +from typeguard import typechecked from webob import Request, Response from webob.exc import HTTPError -from web.core.typing import Context, PathLike, \ +from web.core.typing import Context, PathLike, StatusLike, \ WSGI, WSGIEnvironment, WSGIStartResponse, WSGIStatus, WSGIHeaders, WSGIException, WSGIWriter -StatusLike = Union[int, Response, HTTPError] - - class StatusHandlers: handlers: Mapping[int, str] - def __init__(self, handlers:Optional[Mapping[int, str]]=None): - self.handlers = handlers or {} + 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:StatusLike) -> int: status = getattr(status, 'status_int', getattr(status, 'code', status)) - if not isinstance(status, int): raise TypeError("HTTP status code must be an integer, Response-, or HTTPException-compatible type.") + if not isinstance(status, int): raise TypeError(f"Status must be an integer, 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". @@ -57,15 +57,18 @@ def _maintenance(self) -> bool: # Proxy dictionary-like access through to the underlying status code mapping, normalizing on integer keys. + @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) @@ -73,9 +76,11 @@ def __delitem__(self, status:StatusLike) -> None: # 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.""" + @typechecked def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSGIResponse: """Interposing WSGI middleware to capture start_response and internally redirect if needed.""" From 8fa041ba309ec883f8313a99d01e2629384a2c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:41:39 -0400 Subject: [PATCH 19/35] Proxy through __contains__ checks. --- web/ext/handler.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index f2da895d..935e3076 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -56,6 +56,13 @@ def _maintenance(self) -> bool: 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: @@ -78,7 +85,10 @@ def __delitem__(self, status:StatusLike) -> None: @typechecked def __call__(self, context:Context, app:WSGI) -> WSGI: - """Decorate the WebCore application object with middleware to interpose and internally redirect by status.""" + """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: From d0ea9c5cebcdcce6e5be34a9fd294468c3dbb9e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:43:23 -0400 Subject: [PATCH 20/35] Additional armour against empty handlers. --- web/ext/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index 935e3076..bcb5206b 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -114,7 +114,7 @@ def local_start_response(status:WSGIStatus, headers:WSGIHeaders, exc_info:WSGIEx capture.extend((status, headers, exc_info, self.handlers.get(status_code, None))) result = app(environ, local_start_response) - if not capture: return result + if not capture or not capture[-1]: return result request = Request.blank(capture[-1]) result = request.send(app, catch_exc_info=True) From 2477f654efd3234684bd70c26fe6224e340c5c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:45:33 -0400 Subject: [PATCH 21/35] Utilize "maintenance mode" only if a handler for 503 has been defined. --- web/ext/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index bcb5206b..b4a92e25 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -96,7 +96,7 @@ def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSG capture = [] - if self._maintenance: + if self._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) From 1afbabe485e900c5091e1ae580eb233de48e0259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:47:02 -0400 Subject: [PATCH 22/35] Even more armour! --- web/ext/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/web/ext/handler.py b/web/ext/handler.py index b4a92e25..4c9522ea 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -102,6 +102,7 @@ def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSG start_response(b'503 Service Unavailable', result.headerlist) return result.app_iter + @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.""" From ca41a88163e4fa566ee7946282ace8afd23f67e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:49:54 -0400 Subject: [PATCH 23/35] Cleanup. --- web/ext/handler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index 4c9522ea..34efee18 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -21,11 +21,10 @@ """ from os import environ, getenv as env -from urllib.parse import unquote_plus from typing import Mapping, Optional, Union from typeguard import typechecked -from webob import Request, Response +from webob import Request from webob.exc import HTTPError from web.core.typing import Context, PathLike, StatusLike, \ From c8c042a28470ea4e2e582e78f614dc52efff6f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 12:55:50 -0400 Subject: [PATCH 24/35] Impossible word wrapping, mapping length proxy. --- web/ext/handler.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index 34efee18..f598b25e 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -38,9 +38,12 @@ 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:StatusLike) -> int: - status = getattr(status, 'status_int', getattr(status, 'code', status)) - if not isinstance(status, int): raise TypeError(f"Status must be an integer, Response-, or HTTPException-compatible type, not: {status!r} ({type(status)})") + def _normalize(self, status:Union[str, StatusLike]) -> int: + status = getattr(status, 'status_int', getattr(status, 'code', int(status.partition(' ')[0]))) + + 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 @@ -80,6 +83,11 @@ def __delitem__(self, status:StatusLike) -> None: 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 From a67ddd34840e3b409d3cd40192c54630de29291a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 13:17:09 -0400 Subject: [PATCH 25/35] Initial WIP tests for status code handlers. --- test/test_extension/test_handler.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/test_extension/test_handler.py diff --git a/test/test_extension/test_handler.py b/test/test_extension/test_handler.py new file mode 100644 index 00000000..ce4b7cf1 --- /dev/null +++ b/test/test_extension/test_handler.py @@ -0,0 +1,33 @@ +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_handlers(app): + assert getattr(mock_endpoint, 'state', None) is None + + with MockRequest('/404') as request: + response = request.send(app) + + assert response.text == 'notfound' + assert response.status_int == 404 + assert mock_endpoint.state == 'notfound' + From d49ce5c4e6734414492180eab0c9110f56cc390b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 5 Sep 2023 13:18:49 -0400 Subject: [PATCH 26/35] Test both registered cases. --- test/test_extension/test_handler.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/test_extension/test_handler.py b/test/test_extension/test_handler.py index ce4b7cf1..e8d6ce45 100644 --- a/test/test_extension/test_handler.py +++ b/test/test_extension/test_handler.py @@ -21,9 +21,16 @@ def app(): })]) -def test_handlers(app): - assert getattr(mock_endpoint, 'state', None) is None +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) From 218c998d913c096e7dafc7a5db2bb1656c85132f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 7 Sep 2023 12:54:07 -0400 Subject: [PATCH 27/35] Expand StatusLike to encompass strings. HTTP status codes like "404 Not Found" will be interpreted as the integer 404. --- web/core/typing.py | 2 +- web/ext/handler.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/core/typing.py b/web/core/typing.py index c6c5c214..1964e868 100644 --- a/web/core/typing.py +++ b/web/core/typing.py @@ -27,7 +27,7 @@ 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[int, Response, HTTPError] # Objects usable as an HTTP status. +StatusLike = Union[str, int, Response, HTTPError] # Objects usable as an HTTP status. # Types for WSGI component parts. diff --git a/web/ext/handler.py b/web/ext/handler.py index f598b25e..95947826 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -39,7 +39,8 @@ def __init__(self, handlers:Optional[Mapping[StatusLike, PathLike]]=None): @typechecked def _normalize(self, status:Union[str, StatusLike]) -> int: - status = getattr(status, 'status_int', getattr(status, 'code', int(status.partition(' ')[0]))) + 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)})") From dc449423cf2aebfd883dc759f9316b9e575458d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 7 Sep 2023 12:54:27 -0400 Subject: [PATCH 28/35] Explicitly warn if maintenance mode is enabled w/o maintenance handler. --- web/ext/handler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/ext/handler.py b/web/ext/handler.py index 95947826..224328ed 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -22,6 +22,7 @@ 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 @@ -110,6 +111,9 @@ def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSG start_response(b'503 Service Unavailable', result.headerlist) return result.app_iter + elif self._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.""" From 8674319f2ae28940efff932087824a77fb2a0fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 7 Sep 2023 13:08:38 -0400 Subject: [PATCH 29/35] Calculate maintenance status once, not twice. --- web/ext/handler.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/ext/handler.py b/web/ext/handler.py index 224328ed..98869633 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -104,14 +104,15 @@ def middleware(environ:WSGIEnvironment, start_response:WSGIStartResponse) -> WSG """Interposing WSGI middleware to capture start_response and internally redirect if needed.""" capture = [] + _maintenance: bool = self._maintenance # Calculate this only once. - if self._maintenance and 503 in self.handlers: + 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 self._maintenance: + elif _maintenance: warn("Maintenance mode enabled with no 503 handler available.", RuntimeWarning) @typechecked From cd682da67465613ccbe3a1eb5b26b17c396dbd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 7 Sep 2023 13:08:47 -0400 Subject: [PATCH 30/35] =?UTF-8?q?Add=20note=20about=20WSGIWriter=20return?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/ext/handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/ext/handler.py b/web/ext/handler.py index 98869633..4587e454 100644 --- a/web/ext/handler.py +++ b/web/ext/handler.py @@ -126,6 +126,8 @@ def local_start_response(status:WSGIStatus, headers:WSGIHeaders, exc_info:WSGIEx 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 From 7eb27beaf26cfcb03bea00147a6a05aad50eab66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 7 Sep 2023 16:53:24 -0400 Subject: [PATCH 31/35] Additional comments, incorporate function as classmethod. --- web/core/testing.py | 47 ++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/web/core/testing.py b/web/core/testing.py index 12fa88dd..a3ccb6e3 100644 --- a/web/core/testing.py +++ b/web/core/testing.py @@ -5,7 +5,7 @@ from web.auth import authenticate, deauthenticate from web.core import local from webob import Request, Response -from webob.exc import HTTPOk +from webob.exc import HTTPException, HTTPOk @@ -26,8 +26,14 @@ class MockRequest: data: Optional[Mapping] user: Any - def __init__(self, verb='GET', endpoint='', data=None, user=None, **kw): - self.uri = URI("http://localhost:8080/") / endpoint + 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 @@ -36,23 +42,28 @@ def __init__(self, verb='GET', endpoint='', data=None, user=None, **kw): 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 - - -def validate(app, verb, path, data=None, user=None, expect=None): - if expect is None: expect = HTTPOk - location = None - - print(f"Issuing {verb} request to {path} as {user}, expecting: {expect}") - - 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 + @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 From fe718345e8e67232bd42a77a60186356cbdc2e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 2 Nov 2023 12:36:09 -0400 Subject: [PATCH 32/35] Add Python 3 asynchronous execution support extension. --- web/ext/asynchronous.py | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 web/ext/asynchronous.py diff --git a/web/ext/asynchronous.py b/web/ext/asynchronous.py new file mode 100644 index 00000000..33dfb4ad --- /dev/null +++ b/web/ext/asynchronous.py @@ -0,0 +1,92 @@ +"""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 = 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.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. + """ + + 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 + + if not loop.is_running(): # Thread of execution for asynchronous code if required. + self.thread = Thread(target=self.thread, args=(loop, ), daemon=True, name='async') + + def start(self, context:Context): + """Executed on application startup.""" + + if not self.loop.is_running(): # With no outer async executor, spawn our own in a dedicated thread. + self.thread.start() + + # Expose our event loop at the application scope. + context.loop = self.loop + set_event_loop(self.loop) + + log.debug(f"Asynchronous event loop / reactor: {self.loop!r}") + + context.submit = 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, loop:AbstractEventLoop): + log.warn("Asynchronous event loop / reactor thread spawned.") + set_event_loop(loop) + loop.run_forever() + log.warn("Asynchronous event loop / reactor shut down.") + From 0bacd0bc77d16f95b8113859b4be63c19c9686fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Thu, 2 Nov 2023 14:09:12 -0400 Subject: [PATCH 33/35] Populate application-scope local context in async thread, better handle thread startup. --- web/ext/asynchronous.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/web/ext/asynchronous.py b/web/ext/asynchronous.py index 33dfb4ad..5593de5f 100644 --- a/web/ext/asynchronous.py +++ b/web/ext/asynchronous.py @@ -14,6 +14,8 @@ 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. @@ -27,6 +29,7 @@ async def hello(name): return f"Hello {name}!" from threading import Thread from typing import Optional +from web.core import local from web.core.context import Context @@ -40,6 +43,8 @@ class AsynchronousSupport: 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. @@ -49,23 +54,20 @@ def __init__(self, loop:Optional[AbstractEventLoop]=None): if not loop: loop = get_event_loop() if not loop: loop = new_event_loop() self.loop = loop - - if not loop.is_running(): # Thread of execution for asynchronous code if required. - self.thread = Thread(target=self.thread, args=(loop, ), daemon=True, name='async') def start(self, context:Context): """Executed on application startup.""" - if not self.loop.is_running(): # With no outer async executor, spawn our own in a dedicated thread. - self.thread.start() + 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 - set_event_loop(self.loop) log.debug(f"Asynchronous event loop / reactor: {self.loop!r}") - context.submit = partial(run_coroutine_threadsafe, loop=self.loop) + 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.""" @@ -84,9 +86,9 @@ def stop(self, context:Context): self.loop.call_soon_threadsafe(self.loop.stop) - def thread(self, loop:AbstractEventLoop): + def thread(self, context:Context, loop:AbstractEventLoop): log.warn("Asynchronous event loop / reactor thread spawned.") - set_event_loop(loop) + local.context = context loop.run_forever() log.warn("Asynchronous event loop / reactor shut down.") From 554ac89664f895d60127c51f65b33806fceb4857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 6 Feb 2024 15:57:20 -0500 Subject: [PATCH 34/35] Initial import of WebCore TestingContext fixture. --- web/core/testing.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/web/core/testing.py b/web/core/testing.py index a3ccb6e3..e5d418fd 100644 --- a/web/core/testing.py +++ b/web/core/testing.py @@ -8,6 +8,42 @@ from webob.exc import HTTPException, HTTPOk +''' WIP +from pytest import fixture + + +@fixture(scope='session', autouse=True) +def 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 + + for ext in signals.pre: ext(context) + # notify test began + + yield context + + for ext in signals.after: ext(context) + for ext in signals.done: ext(context) + + # notify suite finished + + for ext in signals.stop: ext(original) + + local.context = original +''' + class MockRequest: """Prepare a new, mocked request. From 7da0a78194907b3788db5a4e2c3301314bf13e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20Zo=C3=AB=20Bevan=E2=80=93McGregor?= Date: Tue, 6 Feb 2024 16:23:14 -0500 Subject: [PATCH 35/35] Test fixture refinement. --- web/core/testing.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/web/core/testing.py b/web/core/testing.py index e5d418fd..dc37df6a 100644 --- a/web/core/testing.py +++ b/web/core/testing.py @@ -13,7 +13,7 @@ @fixture(scope='session', autouse=True) -def context(application): +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 @@ -29,19 +29,24 @@ def context(application): # notify suite began - for ext in signals.pre: ext(context) - # notify test began - yield context - for ext in signals.after: ext(context) - for ext in signals.done: ext(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) '''