Commits

Dan Connolly committed 5045c04

refactor finjax for testability with injector; elaborate docs; start testing

Comments (0)

Files changed (4)

finjax/finjax/__init__.py

 
 '''
 
+# dependencies from pypi; ../README.rst and ../setup.py
+import injector
+from injector import provides, singleton
+from pyramid.config import Configurator
+import sqlalchemy
 
-# dependencies from pypi; ../README.rst and ../setup.py
-from pyramid.config import Configurator
-from sqlalchemy import engine_from_config
-
-from .models import make_session
-from .views import AccountsList, TransactionsQuery
+from .models import DBConfig
+from .views import FinjaxAPI
 
 
 def main(global_config, **settings):
-    """ This function returns a Pyramid WSGI application.
+    """Build finjax Pyramid WSGI application.
 
     @see: `paste.app_factory`__
     __ http://pythonpaste.org/deploy/#paste-app-factory
     @param settings: settings for this application
     @return: a WSGI application.
     """
-    engine = engine_from_config(settings, 'sqlalchemy.')
-    session = make_session()
-    session.configure(bind=engine)
+    finjax, config = RunTime.make(settings, [FinjaxAPI, Configurator])
+    finjax.add_rest_api()
+    return config.make_wsgi_app()
 
-    config = Configurator(settings=settings)
-    config.add_static_view('static', 'static', cache_max_age=3600)
 
-    config.add_route('account', '/account/{guid}')
-    av = AccountsList(session)
-    av.config(config, 'account')
+class RunTime(injector.Module):
+    '''Use runtime config settings to bootstrap dependency injection.
+    '''
 
-    config.add_route('transaction', '/transaction/{guid}')
-    tv = TransactionsQuery(session)
-    tv.config(config, 'transaction')
+    def __init__(self, settings):
+        '''
+        @param settings: as per `paste.app_factory`
+        '''
+        self._settings = settings
 
-    return config.make_wsgi_app()
+    @singleton
+    @provides(Configurator)
+    def app_settings(self):
+        config = Configurator(settings=self._settings)
+        # hmm... hardcoded 'static' literal...
+        config.add_static_view('static', 'static', cache_max_age=3600)
+        return config
+
+    @singleton
+    @provides(sqlalchemy.engine.Engine)
+    def db(self, section='sqlalchemy.'):
+        return sqlalchemy.engine_from_config(self._settings, section)
+
+    @classmethod
+    def make(cls, settings, what):
+        '''Given app settings, instantiate classes with dependency injection.
+
+        @param settings: as per `paste.app_factory`
+        @param what: list of classes to instantiate;
+                     use None as list item to get the whole Injector depgraph.
+        '''
+        mods = [cls(settings), DBConfig()]
+        depgraph = injector.Injector(mods)
+        return [depgraph.get(it) if it else depgraph
+                for it in what]

finjax/finjax/models.py

+'''models -- finjax database access
+'''
+
+import injector
+from injector import inject, provides, singleton
+import sqlalchemy
 from sqlalchemy import (
     Column, ForeignKey,
     Integer, String, Boolean,
 from zope.sqlalchemy import ZopeTransactionExtension
 
 SessionMaker = sessionmaker(extension=ZopeTransactionExtension())
+KSessionMaker = injector.Key('SessionMaker')
 Base = declarative_base()
 
 
-def make_session():
-    return scoped_session(SessionMaker)
+class DBConfig(injector.Module):
+    @singleton
+    @provides(KSessionMaker)
+    @inject(engine=sqlalchemy.engine.Engine)
+    def session_maker(self, engine):
+        sm = scoped_session(SessionMaker)
+        sm.configure(bind=engine)
+        return sm
+
+    @classmethod
+    def mods(cls):
+        return [cls()]
 
 
 class GuidMixin(object):

finjax/finjax/views.py

+'''views -- finjax REST API
+'''
+
 from itertools import groupby
 
+from injector import inject
+from pyramid.config import Configurator
 from pyramid.response import Response
-
 from sqlalchemy.exc import DBAPIError
 
-from .models import (
+from models import (
     Account, Transaction,
-    jrec
+    KSessionMaker
     )
 
 
 class JSONDBView(object):
+    '''View to access DB and render to JSON.
+    '''
+    @inject(session=KSessionMaker)
     def __init__(self, session):
         self._session = session
 
     def config(self, config, route_name):
+        '''Add this view to a Pyramid Configurator.
+        '''
         config.add_view(self, route_name=route_name, renderer='json')
 
+    def __call__(self, request):
+        '''This class is abstract; subclass must implement __call__.
+        '''
+        raise NotImplemented
+
 
 class AccountsList(JSONDBView):
     def __call__(self, request):
         try:
             accounts = self._session.query(Account)
         except DBAPIError:
-            return Response(conn_err_msg,
-                            content_type='text/plain', status_int=500)
+            return DBFailHint
         cols = Account.__table__.columns
         return [dict([(c.name, getattr(acct, c.name)) for c in cols])
                 for acct in accounts]
 
 
 class TransactionsQuery(JSONDBView):
+    '''
+    .. todo:: query by date, account, amount as well as description/memo
+    '''
+    description_memo_query_param = 'q'
+
     def __call__(self, request, limit=200):
-        q = request.params.get('q', None)
+        q = request.params.get(self.description_memo_query_param, None)
         if q is None:
             return Response('missing q param', content_type='text/plain',
                             status_int = 400)
         try:
             matches = dbq[:limit]
         except DBAPIError:
-            return Response(conn_err_msg,
-                            content_type='text/plain', status_int=500)
+            return DBFailHint
 
-        def tx_obj(tx_guid, split_details):
-            return dict(tx_guid=tx_guid,
-                        post_date=split_details[0].post_date.isoformat(),
-                        description=split_details[0].description,
-                        splits=[
-                    dict(split_guid=d.split_guid,
-                         account_guid=d.account_guid,
-                         memo=d.memo,
-                         account_name=d.account_name,
-                         account_type=d.account_type,
-                         value_num=d.value_num,
-                         value_denom=d.value_denom)
-                    for d in split_details])
-
-        return [tx_obj(tx_guid, list(split_details))
+        return [split_denorm(tx_guid, list(split_details))
                 for (tx_guid, split_details)
                 in groupby(matches, lambda m: m.tx_guid)]
 
 
+def split_denorm(tx_guid, split_details):
+    '''De-normalize transaction split info.
+
+    >>> import datetime, pprint
+    >>> when = datetime.datetime(2001, 01, 01, 1, 2, 3)
+    >>> o = split_denorm('tx123',
+    ...         [dotdict(post_date=when, description='fun fun',
+    ...                  split_guid='s456', account_guid='a678',
+    ...                  memo='', tx_guid='tx123',
+    ...                  account_name='Bank X', account_type='BANK',
+    ...                  value_num=-35000, value_denom=100),
+    ...          dotdict(post_date=when, description='fun fun',
+    ...                  split_guid='s654', account_guid='a876',
+    ...                  memo='electric bill', tx_guid='tx123',
+    ...                  account_name='Utilities', account_type='EXPENSE',
+    ...                  value_num=35000, value_denom=100)])
+    >>> pprint.pprint(o)
+    {'description': 'fun fun',
+     'post_date': '2001-01-01T01:02:03',
+     'splits': [{'account_guid': 'a678',
+                 'account_name': 'Bank X',
+                 'account_type': 'BANK',
+                 'memo': '',
+                 'split_guid': 's456',
+                 'value_denom': 100,
+                 'value_num': -35000},
+                {'account_guid': 'a876',
+                 'account_name': 'Utilities',
+                 'account_type': 'EXPENSE',
+                 'memo': 'electric bill',
+                 'split_guid': 's654',
+                 'value_denom': 100,
+                 'value_num': 35000}],
+     'tx_guid': 'tx123'}
+    '''
+    return dict(tx_guid=tx_guid,
+                post_date=split_details[0].post_date.isoformat(),
+                description=split_details[0].description,
+                splits=[
+            dict(split_guid=d.split_guid,
+                 account_guid=d.account_guid,
+                 memo=d.memo,
+                 account_name=d.account_name,
+                 account_type=d.account_type,
+                 value_num=d.value_num,
+                 value_denom=d.value_denom)
+            for d in split_details])
+
+
+class dotdict(dict):
+    '''
+    ack: Darugar Oct 2008
+    http://parand.com/say/index.php/2008/10/24/python-dot-notation-dictionary-access/
+    '''
+    def __getattr__(self, attr):
+        return self.get(attr, None)
+    __setattr__= dict.__setitem__
+    __delattr__= dict.__delitem__
+
+
 conn_err_msg = """\
 Pyramid is having a problem using your SQL database.  The problem
 might be caused by one of the following things:
 try it again.
 """
 
+DBFailHint = Response(conn_err_msg,
+                      content_type='text/plain', status_int=500)
+
+
+class FinjaxAPI(object):
+    '''Finjax REST/JSON API configuration.
+
+    .. todo:: Relax paths. As is, `/account` and `/account/` give 404;
+              some text matching {guid} is required, e.g. `/account/-` .
+    '''
+    account_route = dict(name='account', path='/account/{guid}')
+    transaction_route = dict(name='transaction', path='/transaction/{guid}')
+
+    @inject(config=Configurator,
+            av=AccountsList,
+            tqv=TransactionsQuery)
+    def __init__(self, config, av, tqv):
+        self._config = config
+        self._account_view = av
+        self._transaction_view = tqv
+
+    def add_rest_api(self):
+        for (rt, view) in (
+            (self.account_route, self._account_view),
+            (self.transaction_route, self._transaction_view)):
+            self._config.add_route(rt['name'], rt['path'])
+            view.config(self._config, rt['name'])
 '''setup -- package dependencies for finjax
 
-per `The Hitchhikers Guide to Packaging`__ and PasteDeploy__.
+per `The Hitchhiker's Guide to Packaging`__ and PasteDeploy__.
 
 __ http://guide.python-distribute.org/
 __ http://pythonpaste.org/deploy/
 CHANGES = open(os.path.join(here, 'CHANGES.txt')).read()
 
 requires = [
+    'injector',
     'pyramid',
     'SQLAlchemy',
     'transaction',