Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add code documentation #4

Merged
merged 8 commits into from
Mar 21, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions falcon_sqla/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@

from .manager import Manager

__all__ = [
__all__ = (
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to change this? I'm also a bit of control freak myself loving to make everything immutable (what can be made immutable), however, Python doc examples do use lists.

So IMHO better to stick to that.

'Manager',
]
)
83 changes: 80 additions & 3 deletions falcon_sqla/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,23 @@


class Manager:

"""A manager for SQLAlchemy sessions.

This manager allows registering multiple SQLAlchemy engines, specifying
if they are read-write or read-only or write-only capable.

Args:
engine (Engine): An instance of a SQLAlchemy Engine, usually obtained
with its ``create_engine`` function. This engine is added as
read-write.
session_cls (type, optional): Session class used by this engine to
create the session. Should be a subclass of SQLAlchemy ``Session`
class. Defaults to :class:`.RequestSession`.
binds (dict, optional): A dictionary that allows specifying custom
binds on a per-entity basis in the session. See also
https://docs.sqlalchemy.org/en/13/orm/session_api.html#sqlalchemy.orm.session.Session.params.binds.
Defaults to None.
"""
def __init__(self, engine, session_cls=RequestSession, binds=None):
self._main_engine = engine
self._engines = {engine: 'rw'}
Expand All @@ -39,11 +55,23 @@ def __init__(self, engine, session_cls=RequestSession, binds=None):
self.session_options = SessionOptions()

def _filter_by_role(self, engines, role):
"""Returns all the ``engines`` whose role is exactly ``role``.

NOTE: if no engine with a role is found, all the engine are returned.
"""
filtered = tuple(engine for engine in engines
if self._engines.get(engine) == role)
return filtered or engines

def add_engine(self, engine, role='r'):
"""Adds a new engine with the specified role.

Args:
engine (Engine): An instance of a SQLAlchemy Engine.
role ({'r', 'rw', 'w'}, optional): The role of the engine
('r': read-ony, 'rw': read-write, 'w': write-only).
Defaults to 'r'.
"""
if role not in {'r', 'rw', 'w'}:
raise ValueError("role must be one of ('r', 'rw', 'w')")

Expand All @@ -67,7 +95,11 @@ def add_engine(self, engine, role='r'):
self._session_kwargs = {'_manager_get_bind': self.get_bind}

def get_bind(self, req, resp, session, mapper, clause):
"""Choose the appropriate bind for the given request session."""
"""Choose the appropriate bind for the given request session.

This method is not used directly, it's called by the session instance
if multiple engines are defined.
"""
write = req.method not in self.session_options.safe_methods or (
self.session_options.write_engine_if_flushing and
session._flushing)
Expand All @@ -82,6 +114,7 @@ def get_bind(self, req, resp, session, mapper, clause):
return random.choice(engines)

def get_session(self, req=None, resp=None):
"""Returns a new session object."""
if req and resp:
return self._Session(
info={'req': req, 'resp': resp}, **self._session_kwargs)
Expand All @@ -90,10 +123,12 @@ def get_session(self, req=None, resp=None):

@property
def read_engines(self):
"""A tuple of read capable engines."""
return self._read_engines

@property
def write_engines(self):
"""A tuple of write capable engines."""
return self._write_engines

@contextlib.contextmanager
Expand All @@ -117,10 +152,42 @@ def session_scope(self, req=None, resp=None):

@property
def middleware(self):
"""Returns a new :class:`Middleware` instance connected to this
manager."""
return Middleware(self)


class SessionOptions:
"""Defines a set of configurable options for the session.

An instance of this class is exposed via :attr:`Manager.session_options`.

Attributes:
no_session_methods (frozenset): HTTP methods that by default do not
require a DB session. Defaults to
:attr:`SessionOptions.NO_SESSION_METHODS`.
safe_methods (frozenset): HTTP methods that can use a read-only engine
since they do no alter the state of the db. Defaults to
:attr:`SessionOptions.SAFE_METHODS`.
read_from_rw_engines (bool): When True read operations are allowed from
read-write engines. Only used if more than one engine is defined
in the :class:`Manager`. Defaults to `True`.
write_to_rw_engines (bool): When True write operations are allowed from
read-write engines. Only used if more than one engine is defined
in the :class:`Manager`. Defaults to `True`.
write_engine_if_flushing (bool): When True a write engine is selected
if the session is in flushing state. Only used if more than one
engine is defined in the :class:`Manager`. Defaults to `True`.
sticky_binds (bool): When True the same engine will be used for each
db operation of a particular request. When False the engine will
be chosen randomly from the ones with the required capabilities.
Only used if more than one engine is defined in the
:class:`Manager`. Defaults to `False`.
request_id_func (callabe): A callable object that returns an unique
id for to each session. The returned object must be hashable.
Only used when :attr:`SessionOptions.sticky_binds` is True.
Defaults to ``uuid.uuid4``.
"""
NO_SESSION_METHODS = frozenset(['OPTIONS', 'TRACE'])
"""HTTP methods that by default do not require a DB session."""

Expand All @@ -130,6 +197,16 @@ class SessionOptions:
These methods are assumed to be fine with read-only replica engines.
"""

__slots__ = (
'no_session_methods',
'safe_methods',
'read_from_rw_engines',
'write_to_rw_engines',
'write_engine_if_flushing',
'sticky_binds',
'request_id_func',
)

def __init__(self):
self.no_session_methods = self.NO_SESSION_METHODS
self.safe_methods = self.SAFE_METHODS
Expand All @@ -138,5 +215,5 @@ def __init__(self):
self.write_to_rw_engines = True
self.write_engine_if_flushing = True

self.request_id_func = uuid.uuid4
self.sticky_binds = False
self.request_id_func = uuid.uuid4
12 changes: 12 additions & 0 deletions falcon_sqla/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@


class Middleware:
"""Falcon middleware that can be used with the session manage.

Args:
manager (Manager): Manager instance to use in this middleware.
"""
def __init__(self, manager):
self._manager = manager
self._options = manager.session_options
Expand All @@ -26,6 +30,9 @@ def process_request(self, req, resp):
Set up the SQLAlchemy session for this request.

The session object is stored as ``req.context.session``.
When the option :attr:`SessionOptions.sticky_binds` is set to True an
identification of the request is stored in ``req.context.request_id``
(if not already present).
"""
if req.method not in self._options.no_session_methods:
req.context.session = self._manager.get_session(req, resp)
Expand All @@ -36,7 +43,12 @@ def process_request(self, req, resp):
req.context.session = None

def process_response(self, req, resp, resource, req_succeeded):
"""
Cleans up the session if one was provided.

Finalizes the session by calling commit if `req_succeeded` is True,
rollback otherwise. Finally it will close the session.
"""
def cleanup():
# NOTE(vytas): Break circular references between the request and
# the session.
Expand Down
6 changes: 6 additions & 0 deletions falcon_sqla/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def get_bind(self, mapper=None, clause=None):
"""
Uses the manager to get the appropriate bind when ``_manager_get_bind``
is defined. Otherwise the default logic is used.

This method is called by SQLAlchemy.
"""
if self._manager_get_bind:
return self._manager_get_bind(
session=self, mapper=mapper, clause=clause, **self.info)
Expand Down
10 changes: 10 additions & 0 deletions falcon_sqla/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@


class ClosingStreamWrapper:
"""Iterator that wraps a file-like stream with support for close().

This class is used to wrap WSGI response streams to provide a side effect
when the stream is closed.

Args:
stream (object): Readable file-like stream object.
close (callable): A callable object that is called before the stream
is closed.
"""

def __init__(self, stream, close):
self._stream = stream
Expand Down