From 94df7c071a1f8b582b0a960e6abd28e20609b279 Mon Sep 17 00:00:00 2001 From: Martijn Vermaat <martijn@vermaat.name> Date: Mon, 23 Dec 2013 00:26:52 +0100 Subject: [PATCH] Fix unit tests with SQLAlchemy This involves making the SQLAlchemy session reconfigurable at run-time, which is done automatically on updating the Mutalyzer configuration using configuration update callbacks. --- mutalyzer/Scheduler.py | 4 +- mutalyzer/config/__init__.py | 41 +++++++++++++-- mutalyzer/config/default_settings.py | 3 ++ mutalyzer/db/__init__.py | 67 +++++++++++++++++++----- mutalyzer/db/models.py | 13 +++++ tests/test_crossmap.py | 9 ++-- tests/test_describe.py | 14 ++--- tests/test_grammar.py | 10 ++-- tests/test_mapping.py | 9 ++-- tests/test_mutator.py | 9 ++-- tests/test_parsers_genbank.py | 9 ++-- tests/test_scheduler.py | 77 +++++++++++++++++++++++++--- tests/test_services_json.py | 9 ++-- tests/test_services_soap.py | 8 ++- tests/test_variantchecker.py | 9 ++-- tests/test_website.py | 22 ++------ tests/utils.py | 34 +++++++++--- 17 files changed, 242 insertions(+), 105 deletions(-) diff --git a/mutalyzer/Scheduler.py b/mutalyzer/Scheduler.py index 60ac5711..b675c4b9 100644 --- a/mutalyzer/Scheduler.py +++ b/mutalyzer/Scheduler.py @@ -265,10 +265,10 @@ Mutalyzer batch scheduler""" % url) # NOTE: # Flags is a list of tuples. Each tuple consists of a flag and its # arguments. A skipped entry has only one argument, the selector - # E.g. ("S1", "NM_002001.$") + # E.g. ("S1", "NM_002001.") # An altered entry has three arguments, # old, new negative selector - # E.g.("A2",("NM_002001", "NM_002001.2", "NM_002001[[.period.]]")) + # E.g.("A2",("NM_002001", "NM_002001.2", "NM_002001.")) # Flags are set when an entry could be sped up. This is either the # case for the Retriever as for the Mutalyzer module diff --git a/mutalyzer/config/__init__.py b/mutalyzer/config/__init__.py index 5617162b..4d8d9940 100644 --- a/mutalyzer/config/__init__.py +++ b/mutalyzer/config/__init__.py @@ -6,15 +6,17 @@ module and overridden by any values from the module specified by the `MUTALYZER_SETTINGS`. Alternatively, the default values can be overridden manually using the -:meth:`settings.configure` method before the first use of a configuration -value, in which case the `MUTALYZER_SETTINGS` environment variable will not be -used. +:meth:`settings.configure` method. If this is done before the first use of a +configuration value, the `MUTALYZER_SETTINGS` environment variable will never +be used. """ -import flask.config +import collections import os +import flask.config + from mutalyzer import util @@ -38,9 +40,19 @@ class LazySettings(util.LazyObject): Taken from `Django <https://www.djangoproject.com/>`_ (`django.conf.LazySettings`). + Configuration settings can be updated with the :meth:`configure` method. + + The user can register callbacks to configuration keys that are called + whenever the value for that key is updated with :meth:`on_update`. + .. note:: Django also does some logging config magic here, we did not copy that. """ + def __init__(self, *args, **kwargs): + # Assign to __dict__ to avoid __setattr__ call. + self.__dict__['_callbacks'] = collections.defaultdict(list) + super(LazySettings, self).__init__(*args, **kwargs) + def _setup(self, from_environment=True): """ Load the settings module pointed to by the environment variable. This @@ -60,6 +72,16 @@ class LazySettings(util.LazyObject): self._setup(from_environment=False) self._wrapped.update(settings) + # Callbacks for specific keys. + for key, callbacks in self._callbacks.items(): + if key in settings: + for callback in callbacks: + callback(settings[key]) + + # General callbacks. + for callback in self._callbacks[None]: + callback(settings) + @property def configured(self): """ @@ -67,5 +89,16 @@ class LazySettings(util.LazyObject): """ return self._wrapped is not None + def on_update(self, callback, key=None): + """ + Register a callback for the update of a key (or any update if `key` is + `None`). + + The callback is called with as argument the new value for the updated + key (or a dictionary with all updated key-value pairs if `key` is + `None`). + """ + self._callbacks[key].append(callback) + settings = LazySettings() diff --git a/mutalyzer/config/default_settings.py b/mutalyzer/config/default_settings.py index 3d2e33cd..03dd2a10 100644 --- a/mutalyzer/config/default_settings.py +++ b/mutalyzer/config/default_settings.py @@ -26,6 +26,9 @@ MAX_CACHE_SIZE = 50 * 1048576 # 50 MB # Maximum size for uploaded and downloaded files (in bytes). MAX_FILE_SIZE = 10 * 1048576 # 10 MB +# Database connection URL (can be any SQLAlchemy connection URI). +DATABASE_URI = 'sqlite://' + # Host name for local MySQL databases. MYSQL_HOST = 'localhost' diff --git a/mutalyzer/db/__init__.py b/mutalyzer/db/__init__.py index 41b6910b..8282e824 100644 --- a/mutalyzer/db/__init__.py +++ b/mutalyzer/db/__init__.py @@ -4,27 +4,66 @@ using SQLAlchemy. """ -from sqlalchemy import create_engine +import sqlalchemy +from sqlalchemy.engine.url import make_url from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.pool import StaticPool +from mutalyzer.config import settings -engine = create_engine('sqlite:////tmp/test.db') #, echo=True) -session_factory = sessionmaker(bind=engine) +class SessionFactory(sessionmaker): + """ + Session factory that configures the engine lazily at first use with the + current settings.DATABASE_URI. + """ + def __call__(self, **local_kw): + if self.kw['bind'] is None and 'bind' not in local_kw: + self.kw['bind'] = create_engine() + return super(SessionFactory, self).__call__(**local_kw) -session = scoped_session(session_factory) -Base = declarative_base() -Base.query = session.query_property() +def create_engine(): + """ + Create an SQLAlchemy connection engine from the current configuration. + """ + url = make_url(settings.DATABASE_URI) + options = {} + + if settings.DEBUG: + options.update(echo=True) + + if url.drivername == 'sqlite' and url.database in (None, '', ':memory:'): + # SQLite in-memory database are created per connection, so we need a + # singleton pool if we want to see the same database across threads, + # web requests, etcetera. + options.update( + connect_args={'check_same_thread': False}, + poolclass=StaticPool) + + return sqlalchemy.create_engine(url, **options) + +def configure_session(uri): + """ + (Re)configure the session by closing the existing session if it exists and + loading the current configuration for use by future sessions. + """ + global session_factory, session + session.remove() + session_factory.configure(bind=create_engine()) -def create_database(): - Base.metadata.drop_all(engine) - Base.metadata.create_all(engine) - # if using alembic: - #from alembic.config import Config - #from alembic import command - #alembic_cfg = Config("alembic.ini") - #command.stamp(alembic_cfg, "head") +# Reconfigure the session if database configuration is updated. +settings.on_update(configure_session, 'DATABASE_URI') + + +# Session are automatically created where needed and are scoped by thread. +session_factory = SessionFactory() +session = scoped_session(session_factory) + + +# Base class to use in our models. +Base = declarative_base() +Base.query = session.query_property() diff --git a/mutalyzer/db/models.py b/mutalyzer/db/models.py index 93a5f591..c3fc9b4b 100644 --- a/mutalyzer/db/models.py +++ b/mutalyzer/db/models.py @@ -118,3 +118,16 @@ class BatchQueueItem(db.Base): Index('batch_queue_item_with_batch_job', BatchQueueItem.batch_job_id, BatchQueueItem.id) + + +def create_all(): + db.Base.metadata.drop_all(db.session.get_bind()) + db.Base.metadata.create_all(db.session.get_bind()) + + # Todo: Use alembic. + + # if using alembic: + #from alembic.config import Config + #from alembic import command + #alembic_cfg = Config("alembic.ini") + #command.stamp(alembic_cfg, "head") diff --git a/tests/test_crossmap.py b/tests/test_crossmap.py index d0f60411..489e4252 100644 --- a/tests/test_crossmap.py +++ b/tests/test_crossmap.py @@ -3,20 +3,21 @@ Tests for the Crossmap module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() from nose.tools import * from mutalyzer.Crossmap import Crossmap +import utils + class TestCrossmap(): """ Test the Crossmap class. """ + def setup(self): + utils.create_test_environment(database=True) + def test_splice_sites(self): """ Check whether the gene on the forward strand has the right splice diff --git a/tests/test_describe.py b/tests/test_describe.py index d4a3a96c..9974709a 100644 --- a/tests/test_describe.py +++ b/tests/test_describe.py @@ -3,10 +3,6 @@ Tests for the mutalyzer.describe module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() import os from nose.tools import * @@ -14,17 +10,15 @@ from nose.tools import * import mutalyzer from mutalyzer import describe +import utils + class TestDescribe(): """ Test the mytalyzer.describe module. """ - - def setUp(self): - """ - Nothing. - """ - pass + def setup(self): + utils.create_test_environment() def test1(self): """ diff --git a/tests/test_grammar.py b/tests/test_grammar.py index 2a20abd5..9abf02d8 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -3,10 +3,6 @@ Tests for the mutalyzer.grammar module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() import os from nose.tools import * @@ -15,16 +11,18 @@ import mutalyzer from mutalyzer.grammar import Grammar from mutalyzer.output import Output +import utils + class TestGrammar(): """ Test the mytalyzer.grammar module. """ - - def setUp(self): + def setup(self): """ Initialize test Grammar instance. """ + utils.create_test_environment() self.output = Output(__file__) self.grammar = Grammar(self.output) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 33674e0c..8c4e6c59 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -3,25 +3,24 @@ Tests for the mapping module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() from nose.tools import * from mutalyzer.output import Output from mutalyzer.mapping import Converter +import utils + class TestConverter(): """ Test the Converter class. """ - def setUp(self): + def setup(self): """ Initialize test converter module. """ + utils.create_test_environment(database=True) self.output = Output(__file__) def _converter(self, build): diff --git a/tests/test_mutator.py b/tests/test_mutator.py index 8b32e8f6..6f8391ce 100644 --- a/tests/test_mutator.py +++ b/tests/test_mutator.py @@ -3,10 +3,6 @@ Tests for the mutalyzer.mutator module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() import re import os @@ -19,6 +15,8 @@ from mutalyzer.util import skip from mutalyzer.output import Output from mutalyzer import mutator +import utils + def _seq(length): """ @@ -34,10 +32,11 @@ class TestMutator(): """ Test the mutator module. """ - def setUp(self): + def setup(self): """ Initialize test mutator module. """ + utils.create_test_environment() self.output = Output(__file__) def _mutator(self, sequence): diff --git a/tests/test_parsers_genbank.py b/tests/test_parsers_genbank.py index a04aa5fa..a574cfb4 100644 --- a/tests/test_parsers_genbank.py +++ b/tests/test_parsers_genbank.py @@ -3,24 +3,23 @@ Tests for the mutalyzer.parsers.genbank module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() from nose.tools import * from mutalyzer.parsers import genbank +import utils + class TestMutator(): """ Test the mutator module. """ - def setUp(self): + def setup(self): """ Initialize test mutator module. """ + utils.create_test_environment(database=True) self.gb_parser = genbank.GBparser() def test_product_lists_mismatch(self): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 939de482..4bc3b047 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -3,27 +3,32 @@ Tests for the Scheduler module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - import os import StringIO #import logging; logging.basicConfig() from nose.tools import * +from mutalyzer.config import settings from mutalyzer.db.models import BatchJob, BatchQueueItem from mutalyzer import Db from mutalyzer import File from mutalyzer import output from mutalyzer import Scheduler +import utils + class TestScheduler(): """ Test the Scheduler class. """ + def setup(self): + utils.create_test_environment(database=True) + + def teardown(self): + utils.destroy_environment() + @staticmethod def _batch_job(variants, expected, job_type, argument=None): file_instance = File.File(output.Output('test')) @@ -32,7 +37,7 @@ class TestScheduler(): batch_file = StringIO.StringIO('\n'.join(variants) + '\n') job, columns = file_instance.parseBatchFile(batch_file) result_id = scheduler.addJob('test@test.test', job, columns, - 'webservice', job_type, argument) + None, job_type, argument) left = BatchQueueItem.query \ .join(BatchJob) \ @@ -72,8 +77,6 @@ class TestScheduler(): Simple name checker batch job. """ variants = ['AB026906.1:c.274G>T', - 'NM_000059:c.670dup', - 'NM_000059:c.670G>T', 'NM_000059.3:c.670G>T'] expected = [['AB026906.1:c.274G>T', '(GenRecord): No mRNA field found for gene SDHD, ' @@ -95,7 +98,33 @@ class TestScheduler(): 'AB026906.1(SDHD_i001):p.(Asp92Tyr)', 'CviQI,RsaI', 'BccI'], - ['NM_000059:c.670dup', + ['NM_000059.3:c.670G>T', + '', + 'NM_000059.3', + 'BRCA2_v001', + 'c.670G>T', + 'n.897G>T', + 'c.670G>T', + 'p.(Asp224Tyr)', + 'BRCA2_v001:c.670G>T', + 'BRCA2_v001:p.(Asp224Tyr)', + '', + 'NM_000059.3', + 'NP_000050.2', + 'NM_000059.3(BRCA2_v001):c.670G>T', + 'NM_000059.3(BRCA2_i001):p.(Asp224Tyr)', + '', + 'BspHI,CviAII,FatI,Hpy188III,NlaIII']] + self._batch_job(variants, expected, 'NameChecker') + + def test_name_checker_altered(self): + """ + Name checker job with altered entries. + """ + variants = ['NM_000059:c.670dup', + 'NM_000059:c.670G>T', + 'NM_000059.3:c.670G>T'] + expected = [['NM_000059:c.670dup', '|'.join(['(Retriever): No version number is given, ' 'using NM_000059.3. Please use this number to ' 'reduce downloading overhead.', @@ -152,3 +181,35 @@ class TestScheduler(): '', 'BspHI,CviAII,FatI,Hpy188III,NlaIII']] self._batch_job(variants, expected, 'NameChecker') + + def test_name_checker_skipped(self): + """ + Name checker job with skipped entries. + """ + variants = ['NM_1234567890.3:c.670G>T', + 'NM_1234567890.3:c.570G>T', + 'NM_000059.3:c.670G>T'] + expected = [['NM_1234567890.3:c.670G>T', + '(Retriever): Could not retrieve NM_1234567890.3.|' + '(Scheduler): All further occurrences with ' + '\'NM_1234567890.3\' will be skipped'], + ['NM_1234567890.3:c.570G>T', + '(Scheduler): Skipping entry'], + ['NM_000059.3:c.670G>T', + '', + 'NM_000059.3', + 'BRCA2_v001', + 'c.670G>T', + 'n.897G>T', + 'c.670G>T', + 'p.(Asp224Tyr)', + 'BRCA2_v001:c.670G>T', + 'BRCA2_v001:p.(Asp224Tyr)', + '', + 'NM_000059.3', + 'NP_000050.2', + 'NM_000059.3(BRCA2_v001):c.670G>T', + 'NM_000059.3(BRCA2_i001):p.(Asp224Tyr)', + '', + 'BspHI,CviAII,FatI,Hpy188III,NlaIII']] + self._batch_job(variants, expected, 'NameChecker') diff --git a/tests/test_services_json.py b/tests/test_services_json.py index 35bbd180..122d9566 100644 --- a/tests/test_services_json.py +++ b/tests/test_services_json.py @@ -3,16 +3,14 @@ Tests for the JSON interface to Mutalyzer. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - from nose.tools import * import simplejson as json from spyne.server.null import NullServer import mutalyzer from mutalyzer.services.json import application +import utils + # Todo: We currently have no way of testing POST requests to the JSON API. We # had some tests for this, but they were removed with the new setup [1]. @@ -26,10 +24,11 @@ class TestServicesJson(): """ Test the Mutalyzer HTTP/RPC+JSON interface. """ - def setUp(self): + def setup(self): """ Initialize test server. """ + utils.create_test_environment(database=True) self.server = NullServer(application, ostr=True) def _call(self, method, *args, **kwargs): diff --git a/tests/test_services_soap.py b/tests/test_services_soap.py index cea1ca25..46f3977c 100644 --- a/tests/test_services_soap.py +++ b/tests/test_services_soap.py @@ -3,10 +3,6 @@ Tests for the SOAP interface to Mutalyzer. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - import datetime import logging import os @@ -25,6 +21,7 @@ from mutalyzer.services.soap import application from mutalyzer.sync import CacheSync from mutalyzer.util import slow +import utils # Suds logs an awful lot of things with level=DEBUG, including entire WSDL # files and SOAP responses. On any error, this is all dumped to the console, @@ -52,10 +49,11 @@ class TestServicesSoap(): """ Test the Mutalyzer SOAP interface. """ - def setUp(self): + def setup(self): """ Initialize test server. """ + utils.create_test_environment(database=True) self.server = NullServer(application, ostr=True) # Unfortunately there's no easy way to just give a SUDS client a # complete WSDL string, it only accepts a URL to it. So we create one. diff --git a/tests/test_variantchecker.py b/tests/test_variantchecker.py index 78033e3f..138550f0 100644 --- a/tests/test_variantchecker.py +++ b/tests/test_variantchecker.py @@ -3,10 +3,6 @@ Tests for the variantchecker module. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() from nose.tools import * @@ -15,15 +11,18 @@ from mutalyzer.Db import Cache from mutalyzer.Retriever import GenBankRetriever from mutalyzer.variantchecker import check_variant +import utils + class TestVariantchecker(): """ Test the variantchecker module. """ - def setUp(self): + def setup(self): """ Initialize test variantchecker module. """ + utils.create_test_environment(database=True) self.output = Output(__file__) self.cache_database = Cache() self.retriever = GenBankRetriever(self.output, self.cache_database) diff --git a/tests/test_website.py b/tests/test_website.py index 02b1de9f..8607cac1 100644 --- a/tests/test_website.py +++ b/tests/test_website.py @@ -1,20 +1,10 @@ """ Tests for the WSGI interface to Mutalyzer. -Uses WebTest, see: - http://pythonpaste.org/webtest/ - http://blog.ianbicking.org/2010/04/02/webtest-http-testing/ - -I just installed webtest by 'easy_install webtest'. - @todo: Tests for /upload. """ -from utils import TEST_SETTINGS -from mutalyzer.config import settings -settings.configure(TEST_SETTINGS) - #import logging; logging.basicConfig() import os import re @@ -27,18 +17,11 @@ import logging import urllib import cgi - import mutalyzer from mutalyzer import website from mutalyzer.util import slow, skip - -# TAL logs an awful lot of things with level=DEBUG. On any error, this is all -# dumped to the console, which is very unconvenient. The following suppresses -# most of this. -logging.raiseExceptions = 0 -logging.basicConfig(level=logging.INFO) -logging.getLogger('simpleTAL.HTMLTemplateCompiler').setLevel(logging.ERROR) +import utils BATCH_RESULT_URL = 'http://localhost/mutalyzer/Results_{id}.txt' @@ -49,10 +32,11 @@ class TestWSGI(): Test the Mutalyzer WSGI interface. """ - def setUp(self): + def setup(self): """ Initialize test application. """ + utils.create_test_environment(database=True) web.config.debug = False application = website.app.wsgifunc() self.app = TestApp(application) diff --git a/tests/utils.py b/tests/utils.py index c119d67b..db1c39b6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,14 +1,32 @@ import os +import shutil import tempfile +from mutalyzer.config import settings +from mutalyzer.db import models -log_handle, log_filename = tempfile.mkstemp() -os.close(log_handle) +def create_test_environment(database=False): + """ + Configure Mutalyzer for unit tests. All storage is transient and isolated. + """ + log_handle, log_filename = tempfile.mkstemp() + os.close(log_handle) -TEST_SETTINGS = dict( - DEBUG = True, - TESTING = True, - CACHE_DIR = tempfile.mkdtemp(), - LOG_FILE = log_filename -) + settings.configure(dict( + DEBUG = True, + TESTING = True, + CACHE_DIR = tempfile.mkdtemp(), + DATABASE_URI = 'sqlite://', + LOG_FILE = log_filename)) + + if database: + models.create_all() + + +def destroy_environment(): + """ + Destroy all storage defined in the current environment. + """ + shutil.rmtree(settings.CACHE_DIR) + os.unlink(settings.LOG_FILE) -- GitLab