Changeset - 0ea99a6aab68
[Not reviewed]
0 5 0
Lance Edgar - 10 years ago 2015-03-18 20:03:15
ledgar@sacfoodcoop.com
Various changes to allow custom commands to sit in front of non-Rattail database.

In particular some of the Continuum versioning code required protection
from import failures, and various assumptions about the "default" database
session were refactored.
5 files changed with 67 insertions and 24 deletions:
0 comments (0 inline, 0 general)
rattail/commands.py
Show inline comments
 
@@ -99,24 +99,45 @@ and you are welcome to redistribute it under certain conditions.
 
See the file COPYING.txt for more information.
 
"""
 

	
 
    stdout = sys.stdout
 
    stderr = sys.stderr
 

	
 
    def __init__(self):
 
        self.subcommands = load_entry_points('{0}.commands'.format(self.name))
 

	
 
    def __unicode__(self):
 
        return unicode(self.name)
 

	
 
    @property
 
    def db_config_section(self):
 
        """
 
        Name of section in config file which should have database connection
 
        info.  This defaults to ``'rattail.db'`` but may be overridden so the
 
        command framework can sit in front of a non-Rattail database if needed.
 

	
 
        This is used to auto-configure a "default" database engine for the app,
 
        when any command is invoked.
 
        """
 
        return 'rattail.db'
 

	
 
    @property
 
    def db_session_factory(self):
 
        """
 
        Reference to the "primary" ``Session`` class, which will be configured
 
        automatically during app startup.  Defaults to :class:`rattail.db.Session`.
 
        """
 
        from rattail.db import Session
 
        return Session
 

	
 
    def iter_subcommands(self):
 
        """
 
        Iterate over the subcommands.
 

	
 
        This is a generator which yields each associated :class:`Subcommand`
 
        class sorted by :attr:`Subcommand.name`.
 
        """
 
        for name in sorted(self.subcommands):
 
            yield self.subcommands[name]
 

	
 
    def print_help(self):
 
        """
 
@@ -221,28 +242,30 @@ Commands:\n""".format(self.description, self.name))
 
            # Command line logging flags should override config.
 
            if args.verbose:
 
                log.setLevel(logging.INFO)
 
            if args.debug:
 
                log.setLevel(logging.DEBUG)
 

	
 
            # Configure the default database engine.
 
            try:
 
                from rattail.db.util import configure_session_factory
 
            except ImportError:
 
                pass            # assume no sqlalchemy
 
            else:
 
                configure_session_factory(config)
 
                configure_session_factory(config, section=self.db_config_section,
 
                                          session_factory=self.db_session_factory)
 

	
 
                # Configure Continuum versioning.
 
                if config.getboolean('rattail.db', 'versioning.enabled'):
 
                # Maybe configure Continuum versioning.
 
                if (self.db_config_section == 'rattail.db'
 
                    and config.getboolean('rattail.db', 'versioning.enabled')):
 
                    from rattail.db.continuum  import configure_versioning
 
                    configure_versioning(config)
 

	
 
        # And finally, do something of real value...
 
        cmd = self.subcommands[cmd](self)
 
        cmd.config = config
 
        cmd.show_progress = args.progress
 
        cmd.progress = Progress if args.progress else None
 
        cmd._run(*(args.command + args.argv))
 

	
 

	
 
class Subcommand(object):
 
@@ -535,49 +558,53 @@ class FileMonitorCommand(Subcommand):
 
        # its startup type to be "Automatic (Delayed Start)".
 
        # TODO: Improve this check to include Vista?
 
        if args.subcommand == 'install' and args.auto_start:
 
            if platform.release() == '7':
 
                name = filemon.RattailFileMonitor._svc_name_
 
                service.delayed_auto_start_service(name)
 

	
 

	
 
class ImportSubcommand(Subcommand):
 
    """
 
    Base class for subcommands which use the data importing system.
 
    """
 
    supports_versioning = True
 

	
 
    def add_parser_args(self, parser):
 
        parser.add_argument('--no-versioning', action='store_true',
 
                            help="Disables versioning during the import.  This is "
 
                            "intended to be useful e.g. during initial import, where "
 
                            "the process can be quite slow even without the overhead "
 
                            "of versioning.")
 
        if self.supports_versioning:
 
            parser.add_argument('--no-versioning', action='store_true',
 
                                help="Disables versioning during the import.  This is "
 
                                "intended to be useful e.g. during initial import, where "
 
                                "the process can be quite slow even without the overhead "
 
                                "of versioning.")
 
        parser.add_argument('--dry-run', action='store_true',
 
                            help="Go through the motions and allow logging to occur, "
 
                            "but do not actually commit the transaction at the end.")
 
        parser.add_argument('models', nargs='*', metavar='MODEL',
 
                            help="One or more models to import.  If not specified, "
 
                            "then all supported models will be imported.")
 

	
 
    def run(self, args):
 
        from rattail.db import Session
 
        from rattail.db.continuum  import disable_versioning
 

	
 
        log.info("begin {0} for data model(s): {1}".format(
 
                self.name, ', '.join(args.models or ["ALL"])))
 

	
 
        if args.no_versioning:
 
            disable_versioning()
 
        Session = self.parent.db_session_factory
 
        if self.supports_versioning:
 
            if args.no_versioning:
 
                from rattail.db.continuum  import disable_versioning
 
                disable_versioning()
 
            session = Session(continuum_user=self.continuum_user)
 
        else:
 
            session = Session()
 

	
 
        session = Session(continuum_user=self.continuum_user)
 
        self.import_data(args, session)
 

	
 
        if args.dry_run:
 
            session.rollback()
 
            log.info("dry run, so transaction was rolled back")
 
        else:
 
            session.commit()
 
            log.info("transaction was committed")
 
        session.close()
 

	
 
    @property
 
    def continuum_user(self):
rattail/db/changes.py
Show inline comments
 
@@ -24,25 +24,29 @@
 
Data Changes Interface
 
"""
 

	
 
from __future__ import unicode_literals
 

	
 
import logging
 

	
 
from sqlalchemy.orm import object_mapper, RelationshipProperty
 
from sqlalchemy.orm.interfaces import SessionExtension
 
from sqlalchemy.orm.session import Session
 

	
 
from rattail.core import get_uuid
 
from rattail.db.continuum import versioning_manager
 

	
 
try:
 
    from rattail.db.continuum import versioning_manager
 
except ImportError:             # assume no continuum
 
    versioning_manager = None
 

	
 

	
 
__all__ = ['record_changes']
 

	
 
log = logging.getLogger(__name__)
 

	
 

	
 
def record_changes(session, ignore_role_changes=True):
 
    """
 
    Record all changes which occur within a session.
 

	
 
    :param session: A :class:`sqlalchemy:sqlalchemy.orm.session.Session` class,
 
@@ -76,25 +80,26 @@ class ChangeRecorder(object):
 
    """
 

	
 
    def __init__(self, ignore_role_changes=True):
 
        self.ignore_role_changes = ignore_role_changes
 

	
 
    def __call__(self, session, flush_context, instances):
 
        """
 
        Method invoked when session ``before_flush`` event occurs.
 
        """
 
        # TODO: Not sure if our event replaces the one registered by Continuum,
 
        # or what.  But this appears to be necessary to keep that system
 
        # working when we enable ours...
 
        versioning_manager.before_flush(session, flush_context, instances)
 
        if versioning_manager:
 
            versioning_manager.before_flush(session, flush_context, instances)
 

	
 
        for instance in session.deleted:
 
            log.debug("ChangeRecorder: found deleted instance: {0}".format(repr(instance)))
 
            self.record_change(session, instance, deleted=True)
 
        for instance in session.new:
 
            log.debug("ChangeRecorder: found new instance: {0}".format(repr(instance)))
 
            self.record_change(session, instance)
 
        for instance in session.dirty:
 
            if session.is_modified(instance, passive=True):
 
                # Orphaned objects which really are pending deletion show up in
 
                # session.dirty instead of session.deleted, hence this check.
 
                # See also https://groups.google.com/d/msg/sqlalchemy/H4nQTHphc0M/Xr8-Cgra0Z4J
rattail/db/importing/core.py
Show inline comments
 
@@ -66,24 +66,32 @@ class Importer(Object):
 

	
 
    complex_fields = []
 
    """
 
    Sequence of field names which are considered complex and therefore require
 
    custom logic provided by the derived class, etc.
 
    """
 

	
 
    def __init__(self, config, session, **kwargs):
 
        self.config = config
 
        self.session = session
 
        super(Importer, self).__init__(**kwargs)
 

	
 
    @property
 
    def model_module(self):
 
        """
 
        Reference to a module which contains all available / necessary data
 
        models.  By default this is ``rattail.db.model``.
 
        """
 
        return model
 

	
 
    @property
 
    def model_class(self):
 
        return getattr(model, self.__class__.__name__[:-8])
 

	
 
    @property
 
    def model_name(self):
 
        return self.model_class.__name__
 

	
 
    @property
 
    def simple_fields(self):
 
        return self.supported_fields
 

	
rattail/db/util.py
Show inline comments
 
@@ -139,37 +139,40 @@ def get_default_engine(config, section=None):
 

	
 
def configure_session(config, session):
 
    """
 
    Configure a session factory or instance.  Currently all this does is
 
    install the hook to record changes, if so configured.
 
    """
 
    if config.getboolean('rattail.db', 'changes.record'):
 
        ignore_role_changes = config.getboolean(
 
            'rattail.db', 'changes.ignore_roles', default=True)
 
        record_changes(session, ignore_role_changes)
 

	
 

	
 
def configure_session_factory(config, session_factory=None):
 
def configure_session_factory(config, section='rattail.db', session_factory=None):
 
    """
 
    Configure a session factory using the provided settings.
 

	
 
    :param config: Object containing database configuration.
 

	
 
    :param section: Optional section name within which the configuration
 
       options are defined.  If not specified, ``'rattail.db'`` is assumed.
 

	
 
    :param session_factory: Optional session factory; if none is specified then
 
        :attr:`Session` will be assumed.
 
    """
 
    if session_factory is None:
 
        session_factory = Session
 

	
 
    engine = get_default_engine(config)
 
    engine = get_default_engine(config, section=section)
 
    if engine:
 
        session_factory.configure(bind=engine)
 

	
 
    configure_session(config, session_factory)
 

	
 

	
 
def maxlen(attr):
 
    """
 
    Return the maximum length for the given attribute.
 
    """
 
    if len(attr.property.columns) == 1:
 
        type_ = attr.property.columns[0].type
tests/db/test_init.py
Show inline comments
 
@@ -14,71 +14,71 @@ from edbob.configuration import AppConfigParser
 
from rattail import db
 
from rattail.db.util import configure_session_factory
 

	
 

	
 
class TestConfigureSessionFactory(TestCase):
 

	
 
    def setUp(self):
 
        self.config = AppConfigParser(u'rattail')
 
        self.config.add_section(u'rattail.db')
 
        self.Session = sessionmaker()
 

	
 
    def test_session_is_not_bound_if_no_engine_is_defined_by_config(self):
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        session = self.Session()
 
        self.assertTrue(session.bind is None)
 
        session.close()
 

	
 
    def test_session_is_correctly_bound_if_engine_is_defined_by_config(self):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite:////a/very/custom/db')
 
        session = self.Session()
 
        self.assertTrue(session.bind is None)
 
        session.close()
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        session = self.Session()
 
        self.assertTrue(isinstance(session.bind, Engine))
 
        self.assertEqual(unicode(session.bind.url), u'sqlite:////a/very/custom/db')
 
        session.close()
 

	
 
    def test_global_session_is_configured_by_default(self):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite:////path/to/rattail.sqlite')
 
        session = db.Session()
 
        self.assertTrue(session.bind is None)
 
        session.close()
 
        configure_session_factory(self.config)
 
        session = db.Session()
 
        self.assertTrue(isinstance(session.bind, Engine))
 
        self.assertEqual(unicode(session.bind.url), u'sqlite:////path/to/rattail.sqlite')
 
        session.close()
 
        # Must undo that configuration, this thing is global.
 
        db.Session.configure(bind=None)
 

	
 
    @patch(u'rattail.db.changes.record_changes')
 
    def test_changes_will_not_be_recorded_by_default(self, record_changes):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite://')
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        self.assertFalse(record_changes.called)
 

	
 
    @patch('rattail.db.util.record_changes')
 
    def test_changes_will_be_recorded_by_so_configured(self, record_changes):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite://')
 
        self.config.set(u'rattail.db', u'changes.record', u'true')
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        # Role changes are ignored by default.
 
        record_changes.assert_called_once_with(self.Session, True)
 

	
 
    @patch('rattail.db.util.record_changes')
 
    def test_changes_will_still_be_recorded_with_deprecated_config(self, record_changes):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite://')
 
        self.config.set(u'rattail.db', u'changes.record', u'true')
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        # Role changes are ignored by default.
 
        record_changes.assert_called_once_with(self.Session, True)
 

	
 
    @patch('rattail.db.util.record_changes')
 
    def test_config_determines_if_role_changes_are_ignored(self, record_changes):
 
        self.config.set(u'rattail.db', u'sqlalchemy.url', u'sqlite://')
 
        self.config.set(u'rattail.db', u'changes.record', u'true')
 
        self.config.set(u'rattail.db', u'changes.ignore_roles', u'false')
 
        configure_session_factory(self.config, self.Session)
 
        configure_session_factory(self.config, session_factory=self.Session)
 
        # Role changes are ignored by default; False means config works.
 
        record_changes.assert_called_once_with(self.Session, False)
0 comments (0 inline, 0 general)