Skip to content

Commit

Permalink
Merge pull request #84 from ikalnytskyi/feat/ext-wsgiscopes
Browse files Browse the repository at this point in the history
Add 'application' and 'request' WSGI scopes
  • Loading branch information
ikalnytskyi committed Dec 1, 2023
2 parents 62666e4 + f0473e6 commit b2dd9b1
Show file tree
Hide file tree
Showing 5 changed files with 560 additions and 6 deletions.
9 changes: 9 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
22 changes: 17 additions & 5 deletions src/picobox/ext/flaskscopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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
"""

Expand Down
122 changes: 122 additions & 0 deletions src/picobox/ext/wsgiscopes.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit b2dd9b1

Please sign in to comment.