Skip to content

Commit

Permalink
Simplify: Remove Peto and the Operation concept
Browse files Browse the repository at this point in the history
  • Loading branch information
nandoflorestan committed Mar 12, 2018
1 parent 37b4db8 commit 35c72f3
Show file tree
Hide file tree
Showing 10 changed files with 137 additions and 186 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -40,6 +40,8 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
.mypy_cache/
.pytest_cache/

# Translations
*.mo
Expand Down
66 changes: 42 additions & 24 deletions README.rst
Expand Up @@ -86,7 +86,7 @@ In our solution, the model entities may remain in the center and circulate
(as data holders) through all layers -- this has always been so convenient --,
but the engine that stores and retrieves entities -- here called Repository
(in SQLAlchemy it's the session and its queries) -- must be a
dependency-injected layer.
dependency-injected layer in order to make testing easy.

Fowler and Robert C. Martin would have us put the SQLAlchemy models away too,
so in the future you could more easily swap SQLAlchemy with something else,
Expand All @@ -99,28 +99,36 @@ you can instead declare tables, and then classes, and then mappers that
map tables to the classes. These classes need no specific base class,
so you are free to use them as your entities.)

For now, most importantly, you are able to develop a FakeRepository
for fast unit testing of the Action layer -- or even a MemoryRepository
that stores all entities in RAM.
Since many large apps are assembled from smaller packages, we have devised
a sort of plugin system that composes the final Repository class from
mixin classes.


SQLAlchemy strategy
-------------------

SQLAlchemy creates testability challenges since it is very hard to mock out,
so the following rules must be followed:
Functions are easiest to unit-test when they do not perform IO. Any IO you do
is something that needs to be mocked in tests, and mocking is hard.

- The session must be present in the Repository layer, which is
dependency-injected into the Action layer. This allows you to write
fast unit tests for the Action layer -- by injecting a
FakeRepository object which never touches a RDBMS.
- The session must NOT be present in the model layer (which defines entities).
For now, I think relationships (e.g. ``User.addresses``) and
``object_session(self)`` can continue to exist in models normally,
as long as you think you can implement equivalent relationships in
the other repository strategies with the same API.
- The session must NOT be imported in the Action layer (which contains
business rules). Really, only your Repository object can use the session.
SQLAlchemy is even worse. It creates profound testability challenges for any
code that uses it, because its fluent API is very hard to mock out.

After struggling with the problem for years, we decided that any I/O must
be cleanly decoupled from the Action layer, since this is the most
important layer to be unit-tested. So we follow these rules:

1. The session must be present in the Repository layer, which is
dependency-injected into the Action layer. This allows you to write
fast unit tests for the Action layer -- by injecting a
FakeRepository object which never touches an RDBMS.
2. The session must NOT be present in the model layer (which defines entities).
Usage of SQLAlchemy relationships (e.g. ``User.addresses``), though very
convenient, makes code hard to test because it is doing I/O.
``object_session(self)`` also must be avoided to keep the separation.
For now, I think relationships can continue to exist in models normally,
but they must be used only in the repository.
3. The session must NOT be imported in the Action layer (which contains
business rules). Really, only your Repository object can use the session.


Using Kerno
Expand All @@ -136,8 +144,8 @@ Startup time and request time
Kerno computes some things at startup and keeps the result in a "global" object
which is an instance of the Kerno class. This instance is initialized with
the app's settings and utilities (strategies) are registered on it.
Then it is used on each request to obtain globals.
Each request consists of a call to an Action.

Then each request uses that to obtain globals and calls an Action.


Component registration
Expand All @@ -151,9 +159,19 @@ Reg is very powerful and you don't need to create an interface for
each component you want to register.


Composable actions
==================
Actions
=======

You can express Kerno actions (the service layer) as functions or as classes.
Kerno provides a base class for this purpose.


Web framework integration
=========================

Kerno is trying to provide a good scheme to communicate with web frameworks
in general.

Kerno provides a base class for actions (the service layer). If you follow
the pattern, then you can create actions composed of other actions, which
might be useful to you.
Integration with Pyramid is provided, but totally decoupled and optional.
It includes an Exception class, a view that catches and renders it,
and conventions for returned objects.
26 changes: 5 additions & 21 deletions ROADMAP.rst
Expand Up @@ -5,39 +5,23 @@ Kerno roadmap
Because Kerno is so young, this document is a mess. Do not
read it now; I'll tidy it up in the future.

- Maybe the action is easier to test if it suffers automatic dependency injection?
- ValidatingOperation adds ValidationAction before the main action.
- Generalize mixin composition, for applications made of multiple modules:
``config.add_mixin(to_assemble='repository', MyRepositoryPart)``
- A start procedure in which plugins (the modules of the system) are found
and initialized. Start with the repository.
- mixin composition, for applications made of multiple modules:
``config.add_mixin(to_assemble='repository', MyRepositoryPart)``

- Actions are composable: validator actions, then main actions, then logging.
- Register schemas: ``schemas.register(action=CreateUser, MySchema, petition)``
- Register logging functions: ``logging.register(action=CreateUser, MyLogger, petition)``
- Actions are undoable: Command, History
- A good scheme to communicate with the web framework of choice. This might
include good Exception classes, or just a convention for the returned objects.
- Optional Pyramid integration with total decoupling. Integration could mean,
for instance, just something that renders our exceptions into responses.
- action interface
- strategies
- operation logging
- storage
- sqlalchemy
- Automatically imports modules' storage_sqlalchemy.py and composes
the final Repository class out of mixins.
- app_name setting
- app_package setting
- The Repository strategy is a plugin and it is dependency-injected according to
configuration, making it easy to create a FakeRepository for fast unit testing.
- modules_package setting (undocumented, default 'modules')
- modules directory
- keepluggable as a plugin?
- burla, to register and generate URLs. Alternatively, explain to me how
- A component to register and generate URLs. Alternatively, explain to me how
URL generation can be left to the controller layer outside of this project.
I don't think it can.
- Hook documentation for the implemented actions
- Optional SQLAlchemy integration, but at the right distance.
- Optional SQLAlchemy transaction integration, but at the right distance.
For instance, research if it is possible to commit the transaction
after an action, and still return to the controller the (now harmless)
detached entities.
Expand Down
11 changes: 6 additions & 5 deletions concepts.rst → dreams.rst
@@ -1,14 +1,15 @@
========
Concepts
Dreams
========

Kerno wishes to support:
Kerno would like to support, in the future:

- the storage of a log of commands (for auditing, telling us who did what);
- live updating of a UI - for example, through WebSockets;
- an undo feature.
- live updating of a UI - for example, through WebSockets - by creating
channels which users subscribe;
- an undo feature (but this seems very hard to achieve with SQLAlchemy).

To this end, Kerno shall use the following concepts:
To this end, Kerno might use the following concepts:

**User**: The user (or system component) that performs an Operation.

Expand Down
81 changes: 62 additions & 19 deletions kerno/action.py
@@ -1,30 +1,73 @@
"""Action base class."""
"""A registry for all the business operations of an application."""

from abc import ABCMeta, abstractmethod
from types import FunctionType
from .core import Kerno
from .state import Rezulto

from typing import TYPE_CHECKING, Any, Callable, Union, Type, cast
if TYPE_CHECKING:
from typing import Dict # noqa

class Action(metaclass=ABCMeta):
"""Base for Action classes. Actions are composable and chainable.

If you don't need undo functionality, you may skip subclassing Action and
just write a function that takes the argument ``peto``.
Instead of returning something, it should modify ``peto.rezulto``.
"""
class Action(metaclass=ABCMeta):
"""Abstract base for class-based actions."""

def __init__(self, peto): # , **kw):
"""Constructor."""
self.peto = peto
# for key, val in kw.items():
# setattr(self, key, val)
def __init__(self, kerno: Kerno, user: Any, repo: Any) -> None:
"""Construct."""
self.kerno = kerno
self.user = user
self.repo = repo

@abstractmethod
def __call__(self):
"""Override this method to do the main work of the action.
def __call__(self, **kw) -> Rezulto:
"""Must be overridden in subclasses and return a Rezulto."""


TypActionFunction = Callable[..., Rezulto] # a function that returns a Rezulto
TypActionClass = Type[Action] # a subclass of Action
TypAction = Union[TypActionClass, TypActionFunction] # either of these


class ActionRegistry:
"""Kerno's action registry."""

Instead of returning something, modify self.peto.rezulto.
def __init__(self, kerno: Kerno) -> None:
"""Construct.
``kerno`` must be the Kerno instance for the current application.
"""
pass
self.kerno = kerno
self.actions = {} # type: Dict[str, TypAction]

def add(self, name: str, action: TypAction) -> None:
"""Register an Action under ``name`` at startup for later use."""
# assert callable(action)
if name in self.actions:
raise ValueError(
'An action with the name {} is already registered.'.format(
name))
self.actions[name] = action

def remove(self, name: str) -> None:
"""Delete the action with ``name``."""
del self.actions[name]

# @abstractmethod # prevents instantiation when not implemented
# def undo(self):
"""Optionally also implement undo for this action."""
def run(self, name: str, user: Any, repo: Any, **kw) -> Rezulto:
"""Execute, as ``user``, the action stored under ``name``.
``user`` is the User instance requesting the current operation.
This method will become more complex when we introduce events.
"""
# when = when or datetime.utcnow()
action = self.actions[name]
if issubclass(cast(TypActionClass, action), Action):
action_instance = cast(Action, action(
kerno=self.kerno, user=user, repo=repo))
return action_instance(**kw)
elif isinstance(action, FunctionType):
return action(kerno=self.kerno, user=user, repo=repo, **kw)
else:
raise TypeError(
'"{}" is not a function or an Action subclass!'.format(action))
11 changes: 6 additions & 5 deletions kerno/core.py
Expand Up @@ -3,15 +3,14 @@
from configparser import NoSectionError
import reg
from bag.settings import read_ini_files, resolve
from .operation import OperationRegistry
from .repository import RepositoryAssembler


class UtilityRegistry:
"""Mixin that contains Kerno's utility registry."""

def __init__(self):
"""Constructor section."""
"""Construct."""
# The registry must be local to each Kerno instance, not static.
# This is why we define the registry inside the constructor:
@reg.dispatch(reg.match_key('name', lambda name: name))
Expand Down Expand Up @@ -49,7 +48,7 @@ def ensure_utility(self, name, component='The application'):
'which has not been registered.'.format(component, name))


class Kerno(UtilityRegistry, OperationRegistry, RepositoryAssembler):
class Kerno(UtilityRegistry, RepositoryAssembler):
"""Core of an application, integrating decoupled resources."""

@classmethod
Expand All @@ -58,11 +57,13 @@ def from_ini(cls, *config_files, encoding='utf-8'):
return cls(settings=read_ini_files(*config_files, encoding=encoding))

def __init__(self, settings=None):
"""The ``settings`` are a dict of dicts."""
"""Construct. The ``settings`` are a dict of dicts."""
from .action import ActionRegistry
if settings and not hasattr(settings, '__getitem__'):
raise TypeError("The *settings* argument must be dict-like. "
"Received: {}".format(type(settings)))
self.settings = settings
self.actions = ActionRegistry(kerno=self)

UtilityRegistry.__init__(self)
OperationRegistry.__init__(self)
RepositoryAssembler.__init__(self)
69 changes: 0 additions & 69 deletions kerno/operation.py

This file was deleted.

2 changes: 1 addition & 1 deletion kerno/repository/sqlalchemy.py
@@ -1,4 +1,4 @@
"""This module contains a base class for SQLAlchemy-based repositories."""
"""A base class for SQLAlchemy-based repositories."""


class BaseSQLAlchemyRepository:
Expand Down

0 comments on commit 35c72f3

Please sign in to comment.