From f0473e6fda1ff3e368b1bc6ab0550f8bc1b38816 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Fri, 1 Dec 2023 02:32:05 +0200 Subject: [PATCH] Add 'application' and 'request' WSGI scopes WSGI is a specification that describes how a web server communicates with web applications, and how web applications can be chained together to process one request (PEP 3333). Most *synchronous* web frameworks for Python (e.g., Flask, Django, Pyramid) sit on top of this specification. Resolves: #81 --- docs/index.rst | 9 + pyproject.toml | 2 +- src/picobox/ext/flaskscopes.py | 22 +- src/picobox/ext/wsgiscopes.py | 122 ++++++++++ tests/ext/test_wsgiscopes.py | 411 +++++++++++++++++++++++++++++++++ 5 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 src/picobox/ext/wsgiscopes.py create mode 100644 tests/ext/test_wsgiscopes.py diff --git a/docs/index.rst b/docs/index.rst index b2d3cbb..acf5d5f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -330,6 +330,15 @@ Scopes .. autodata:: noscope :annotation: +.. autodata:: picobox.ext.wsgiscopes.ScopeMiddleware + :annotation: + +.. autodata:: picobox.ext.wsgiscopes.application + :annotation: + +.. autodata:: picobox.ext.wsgiscopes.request + :annotation: + .. autodata:: picobox.ext.flaskscopes.application :annotation: diff --git a/pyproject.toml b/pyproject.toml index 1fdcd19..868c939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ select = [ "ERA", "RUF", ] -ignore = ["D203", "D213", "D401", "S101", "B904", "ISC001", "PT011", "SIM117"] +ignore = ["D107", "D203", "D213", "D401", "S101", "B904", "ISC001", "PT011", "SIM117"] line-length = 100 [tool.ruff.isort] diff --git a/src/picobox/ext/flaskscopes.py b/src/picobox/ext/flaskscopes.py index 500e0dd..4fe9789 100644 --- a/src/picobox/ext/flaskscopes.py +++ b/src/picobox/ext/flaskscopes.py @@ -45,12 +45,17 @@ def get(self, key): class application(_flaskscope): """Share instances across the same Flask (HTTP) application. - In most cases can be used interchangeably with :class:`picobox.singleton` - scope. Comes around when you have `multiple Flask applications`__ and you - want to have independent instances for each Flask application, despite - the fact they are running in the same WSGI context. + In typical scenarios, a single Flask application exists, making this scope + interchangeable with :class:`picobox.singleton`. However, unlike the + latter, the application scope ensures that dependencies are bound to the + lifespan of a specific application instance. This is particularly useful in + testing scenarios where each test involves creating a new application + instance or in situations where you have `multiple Flask applications`__. - .. __: http://flask.pocoo.org/docs/1.0/patterns/appdispatch/ + .. __: https://flask.palletsprojects.com/en/3.0.x/patterns/appdispatch/ + + Unlike :class:`picobox.ext.wsgiscopes.application`, it requires no WSGI + middlewares. .. versionadded:: 2.2 """ @@ -63,6 +68,13 @@ def _store(self): class request(_flaskscope): """Share instances across the same Flask (HTTP) request. + You might want to store your SQLAlchemy session or Request-ID per request. + In many cases this produces much more readable code than passing the whole + request context around. + + Unlike :class:`picobox.ext.wsgiscopes.request`, it requires no WSGI + middlewares. + .. versionadded:: 2.2 """ diff --git a/src/picobox/ext/wsgiscopes.py b/src/picobox/ext/wsgiscopes.py new file mode 100644 index 0000000..e35d2a6 --- /dev/null +++ b/src/picobox/ext/wsgiscopes.py @@ -0,0 +1,122 @@ +"""Scopes for WSGI applications.""" + +import contextvars +import typing as t +import weakref + +import picobox + +if t.TYPE_CHECKING: + from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment + + +_current_app_store = contextvars.ContextVar(f"{__name__}.current-app-store") +_current_req_store = contextvars.ContextVar(f"{__name__}.current-req-store") + + +class ScopeMiddleware: + """A WSGI middleware that defines scopes for Picobox. + + For the proper functioning of :class:`application` and :class:`request` + scopes, it is essential to integrate this middleware into your WSGI + application. Otherwise, the aforementioned scopes will be inoperable. + + .. code:: python + + from picobox.ext import wsgiscopes + app = wsgiscopes.ScopeMiddleware(app) + + :param app: The WSGI application to wrap. + """ + + def __init__(self, app: "WSGIApplication") -> None: + self.app = app + # Since we want stored objects to be garbage collected as soon as the + # storing scope instance is destroyed, scope instances have to be + # weakly referenced. + self.store = weakref.WeakKeyDictionary() + + def __call__( + self, + environ: "WSGIEnvironment", + start_response: "StartResponse", + ) -> t.Iterable[bytes]: + """Define scopes and invoke the WSGI application.""" + # Storing the WSGI application's scope state within a ScopeMiddleware + # instance because it's assumed that each WSGI middleware is typically + # applied once to a given WSGI application. By keeping the application + # scope state in the middleware, we facilitate support for multiple + # simultaneous WSGI applications (e.g., in nested execution scenarios). + app_store_token = _current_app_store.set(self.store) + req_store_token = _current_req_store.set(weakref.WeakKeyDictionary()) + + try: + rv = self.app(environ, start_response) + finally: + _current_req_store.reset(req_store_token) + _current_app_store.reset(app_store_token) + return rv + + +class _wsgiscope(picobox.Scope): + """A base class for WSGI scopes.""" + + _store_cvar: contextvars.ContextVar + + @property + def _store(self) -> t.MutableMapping[t.Hashable, t.Any]: + try: + store = self._store_cvar.get() + except LookupError: + raise RuntimeError( + "Working outside of WSGI context.\n" + "\n" + "This typically means that you attempted to use picobox with " + "WSGI scopes, but 'picobox.ext.wsgiscopes.ScopeMiddleware' has " + "not been used with your WSGI application." + ) + + try: + store = store[self] + except KeyError: + store = store.setdefault(self, {}) + return store + + def set(self, key: t.Hashable, value: t.Any) -> None: + self._store[key] = value + + def get(self, key: t.Hashable) -> t.Any: + return self._store[key] + + +class application(_wsgiscope): + """Share instances across the same WSGI application. + + In typical scenarios, a single WSGI application exists, making this scope + interchangeable with :class:`picobox.singleton`. However, unlike the + latter, the application scope ensures that dependencies are bound to the + lifespan of a specific application instance. This is particularly useful in + testing scenarios where each test involves creating a new application + instance or in situations where applications are nested. + + Requires :class:`ScopeMiddleware`; otherwise ``RuntimeError`` is thrown. + + .. versionadded:: 4.1 + """ + + _store_cvar = _current_app_store + + +class request(_wsgiscope): + """Share instances across the same WSGI (HTTP) request. + + You might want to store your SQLAlchemy session or Request-ID per request. + In many cases this produces much more readable code than passing the whole + request context around. + + Requires :class:`ScopeMiddleware`; otherwise ``RuntimeError`` is thrown. + + .. versionadded:: 4.1 + """ + + _store_cvar = _current_req_store diff --git a/tests/ext/test_wsgiscopes.py b/tests/ext/test_wsgiscopes.py new file mode 100644 index 0000000..3b1c06a --- /dev/null +++ b/tests/ext/test_wsgiscopes.py @@ -0,0 +1,411 @@ +"""Test WSGI scopes.""" + +import concurrent.futures +import threading + +import flask +import flask.testing +import pytest + +from picobox.ext import wsgiscopes + + +def run_in_thread(function, *args, **kwargs): + closure = {} + + def target(): + try: + closure["ret"] = function(*args, **kwargs) + except Exception as e: + closure["exc"] = e + + worker = threading.Thread(target=target) + worker.start() + worker.join() + + if "exc" in closure: + raise closure["exc"] + return closure["ret"] + + +@pytest.fixture() +def app_factory(): + """A factory that creates test application instances.""" + + def factory(*routes, with_scope_middleware=True): + app = flask.Flask("testapp") + + for rule, func in routes: + + def view_func(func=func): + return func() or "" + + app.add_url_rule(rule, endpoint=rule, view_func=view_func) + + if with_scope_middleware: + app.wsgi_app = wsgiscopes.ScopeMiddleware(app.wsgi_app) + + # Since tests below use 'assert' statements inside the routes, we want + # these assertion errors from the routes to be propagated all the way + # up to tests and not being converted into '500 Internal Server Error'. + app.config["PROPAGATE_EXCEPTIONS"] = True + return app + + return factory + + +@pytest.fixture() +def testclient_factory(): + """A factory that creates test client instances.""" + + def factory(app): + return app.test_client() + + return factory + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_set_key(app_factory, testclient_factory, scope_factory, supported_key): + scope = scope_factory() + + def endpoint(): + scope.set(supported_key, "the-value") + assert scope.get(supported_key) == "the-value" + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_set_value(app_factory, testclient_factory, scope_factory, supported_value): + scope = scope_factory() + + def endpoint(): + scope.set("the-value", supported_value) + assert scope.get("the-value") is supported_value + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_set_overwrite(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint(): + scope.set("the-key", value) + assert scope.get("the-key") is value + + scope.set("the-key", "overwrite") + assert scope.get("the-key") == "overwrite" + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_get_keyerror(app_factory, testclient_factory, scope_factory, supported_key): + scope = scope_factory() + + def endpoint(): + with pytest.raises(KeyError) as excinfo: + scope.get(supported_key) + assert str(excinfo.value) == f"{supported_key!r}" + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_state_not_shared_between_instances(app_factory, testclient_factory, scope_factory): + scope_a = scope_factory() + value_a = object() + + scope_b = scope_factory() + value_b = object() + + def endpoint(): + scope_a.set("the-key", value_a) + assert scope_a.get("the-key") is value_a + + with pytest.raises(KeyError) as excinfo: + scope_b.get("the-key") + assert str(excinfo.value) == "'the-key'" + + scope_b.set("the-key", value_b) + assert scope_b.get("the-key") is value_b + + assert scope_a.get("the-key") is value_a + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + ], +) +def test_scope_value_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint1(): + scope.set("the-key", value) + + def endpoint2(): + assert scope.get("the-key") is value + + client = testclient_factory( + app_factory( + ("/1", endpoint1), + ("/2", endpoint2), + ) + ) + client.get("/1") + client.get("/2") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.request, + ], +) +def test_scope_value_not_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint1(): + scope.set("the-key", value) + + def endpoint2(): + with pytest.raises(KeyError) as excinfo: + assert scope.get("the-key") is value + assert str(excinfo.value) == "'the-key'" + + client = testclient_factory( + app_factory( + ("/1", endpoint1), + ("/2", endpoint2), + ) + ) + client.get("/1") + client.get("/2") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_value_downstack_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint(): + scope.set("the-key", value) + subroutine() + + def subroutine(): + assert scope.get("the-key") is value + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_value_downstack_thread_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint(): + scope.set("the-key", value) + run_in_thread(subroutine) + + def subroutine(): + with pytest.raises(RuntimeError) as excinfo: + assert scope.get("the-key") is value + + assert str(excinfo.value) == ( + "Working outside of WSGI context.\n" + "\n" + "This typically means that you attempted to use picobox with WSGI " + "scopes, but 'picobox.ext.wsgiscopes.ScopeMiddleware' has not " + "been used with your WSGI application." + ) + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_value_upstack_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint(): + subroutine() + assert scope.get("the-key") is value + + def subroutine(): + scope.set("the-key", value) + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_value_upstack_thread_shared(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + value = object() + + def endpoint(): + run_in_thread(subroutine) + + def subroutine(): + with pytest.raises(RuntimeError) as excinfo: + scope.set("the-key", value) + + assert str(excinfo.value) == ( + "Working outside of WSGI context.\n" + "\n" + "This typically means that you attempted to use picobox with WSGI " + "scopes, but 'picobox.ext.wsgiscopes.ScopeMiddleware' has not " + "been used with your WSGI application." + ) + + client = testclient_factory(app_factory(("/", endpoint))) + client.get("/") + + +def test_scope_application_is_application_bound(app_factory, testclient_factory): + scope = wsgiscopes.application() + value = object() + + def endpoint1(): + scope.set("the-key", value) + assert scope.get("the-key") is value + + def endpoint2(): + with pytest.raises(KeyError) as excinfo: + scope.get("the-key") + assert str(excinfo.value) == "'the-key'" + + client1 = testclient_factory(app_factory(("/1", endpoint1))) + client2 = testclient_factory(app_factory(("/2", endpoint2))) + + client1.get("/1") + client2.get("/2") + + +def test_scope_request_is_request_bound(app_factory, testclient_factory): + scope = wsgiscopes.request() + value = object() + event1 = threading.Event() + event2 = threading.Event() + + def endpoint1(): + scope.set("the-key", value) + event1.set() + event2.wait(timeout=1) + assert scope.get("the-key") is value + + def endpoint2(): + event1.wait(timeout=1) + with pytest.raises(KeyError) as excinfo: + scope.get("the-key") + assert str(excinfo.value) == "'the-key'" + event2.set() + + client = testclient_factory( + app_factory( + ("/1", endpoint1), + ("/2", endpoint2), + ) + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(client.get, "/1") + future2 = executor.submit(client.get, "/2") + + for future in concurrent.futures.as_completed({future1, future2}): + future.result() + + +@pytest.mark.parametrize( + "scope_factory", + [ + wsgiscopes.application, + wsgiscopes.request, + ], +) +def test_scope_wo_middleware(app_factory, testclient_factory, scope_factory): + scope = scope_factory() + + def endpoint(): + with pytest.raises(RuntimeError) as excinfo: + scope.set("the-key", "the-value") + + assert str(excinfo.value) == ( + "Working outside of WSGI context.\n" + "\n" + "This typically means that you attempted to use picobox with WSGI " + "scopes, but 'picobox.ext.wsgiscopes.ScopeMiddleware' has not " + "been used with your WSGI application." + ) + + client = testclient_factory(app_factory(("/", endpoint), with_scope_middleware=False)) + client.get("/")