diff --git a/src/wuttjamaican/db/conf.py b/src/wuttjamaican/db/conf.py index 8753fa1..ceb382a 100644 --- a/src/wuttjamaican/db/conf.py +++ b/src/wuttjamaican/db/conf.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2024 Lance Edgar +# Copyright © 2023-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -27,6 +27,9 @@ WuttJamaican - database configuration from collections import OrderedDict import sqlalchemy as sa +from alembic.config import Config as AlembicConfig +from alembic.script import ScriptDirectory +from alembic.migration import MigrationContext from wuttjamaican.util import load_object, parse_bool, parse_list @@ -150,3 +153,93 @@ def make_engine_from_config(config_dict, prefix="sqlalchemy.", **kwargs): engine = sa.engine_from_config(config_dict, prefix, **kwargs) return engine + + +############################## +# alembic functions +############################## + + +def make_alembic_config(config): + """ + Make and return a new Alembic config object, based on current app + config. + + This tries to set the following on the Alembic config: + + * :attr:`~alembic:alembic.config.Config.config_file_name` - set to + app's primary config file + * main option ``script_location`` + * main option ``version_locations`` + + The latter 2 are read normally from app config, then set on the + Alembic config via + :meth:`~alembic:alembic.config.Config.set_main_option()`. + + .. note:: + + IIUC, Alembic should not need to attempt to read config values + from file, as long as we're able to set the above explicitly. + However we set the ``config_file_name`` "just in case" Alembic + needs it, but also to ensure it is discoverable from within the + ``env.py`` script... + + When a migration script runs, code within ``env.py`` will call + :func:`make_config()` using the filename which it inspects from + the Alembic config. + + (Confused yet?!) + + :returns: :class:`alembic:alembic.config.Config` instance + """ + alembic_config = AlembicConfig() + + # TODO: not sure what we can do here besides assume the "primary" + # config file should be used? + if config.files_read: + alembic_config.config_file_name = config.get_prioritized_files()[0] + + if script_location := config.get("alembic.script_location", usedb=False): + alembic_config.set_main_option("script_location", script_location) + + if version_locations := config.get("alembic.version_locations", usedb=False): + alembic_config.set_main_option("version_locations", version_locations) + + return alembic_config + + +def get_alembic_scriptdir(config, alembic_config=None): + """ + Get a "Script Directory" object for Alembic. + + This allows for inspection of the migration scripts. + + :param config: App config object. + + :param alembic_config: Alembic config object, if you have one. + Otherwise :func:`make_alembic_config()` will be called. + + :returns: :class:`~alembic:alembic.script.ScriptDirectory` instance + """ + if not alembic_config: + alembic_config = make_alembic_config(config) + return ScriptDirectory.from_config(alembic_config) + + +def check_alembic_current(config, alembic_config=None): + """ + Compare the current revisions in the :term:`app database` to those + found in the migration scripts. + + :param config: App config object. + + :param alembic_config: Alembic config object, if you have one. + Otherwise :func:`make_alembic_config()` will be called. + + :returns: ``True`` if the DB already has all migrations applied; + ``False`` if not. + """ + script = get_alembic_scriptdir(config, alembic_config) + with config.appdb_engine.begin() as conn: + context = MigrationContext.configure(conn) + return set(context.get_current_heads()) == set(script.get_heads()) diff --git a/src/wuttjamaican/testing.py b/src/wuttjamaican/testing.py index 2744e63..76a1da0 100644 --- a/src/wuttjamaican/testing.py +++ b/src/wuttjamaican/testing.py @@ -176,9 +176,10 @@ class ConfigTestCase(FileTestCase): """ self.teardown_files() - def make_config(self, **kwargs): # pylint: disable=empty-docstring - """ """ - return WuttaConfig(**kwargs) + def make_config( # pylint: disable=missing-function-docstring + self, files=None, **kwargs + ): + return WuttaConfig(files, **kwargs) class DataTestCase(ConfigTestCase): @@ -213,6 +214,8 @@ class DataTestCase(ConfigTestCase): teardown methods, as this class handles that automatically. """ + sqlite_engine_url = "sqlite://" + def setUp(self): # pylint: disable=empty-docstring """ """ self.setup_db() @@ -237,8 +240,7 @@ class DataTestCase(ConfigTestCase): """ self.teardown_config() - def make_config(self, **kwargs): # pylint: disable=empty-docstring - """ """ + def make_config(self, files=None, **kwargs): defaults = kwargs.setdefault("defaults", {}) - defaults.setdefault("wutta.db.default.url", "sqlite://") - return super().make_config(**kwargs) + defaults.setdefault("wutta.db.default.url", self.sqlite_engine_url) + return super().make_config(files, **kwargs) diff --git a/tests/db/test_conf.py b/tests/db/test_conf.py index cb68c0e..5e7c57d 100644 --- a/tests/db/test_conf.py +++ b/tests/db/test_conf.py @@ -6,13 +6,18 @@ import tempfile from unittest import TestCase from wuttjamaican.conf import WuttaConfig +from wuttjamaican.testing import ConfigTestCase, DataTestCase try: import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.engine import Engine from sqlalchemy.pool import NullPool + from alembic import command as alembic_command + from alembic.config import Config as AlembicConfig + from alembic.script import ScriptDirectory from wuttjamaican.db import conf + from wuttjamaican.db import conf as mod except ImportError: pass else: @@ -154,3 +159,111 @@ else: } ) self.assertTrue(engine.pool._pre_ping) + + class TestMakeAlembicConfig(ConfigTestCase): + + def test_defaults(self): + + # without config file + self.assertFalse(self.config.files_read) + alembic = mod.make_alembic_config(self.config) + self.assertIsInstance(alembic, AlembicConfig) + self.assertIsNone(alembic.config_file_name) + self.assertIsNone(alembic.get_main_option("script_location")) + self.assertIsNone(alembic.get_main_option("version_locations")) + + # with config file + path = self.write_file("test.ini", "[alembic]") + self.config.files_read = [path] + alembic = mod.make_alembic_config(self.config) + self.assertIsInstance(alembic, AlembicConfig) + self.assertEqual(alembic.config_file_name, path) + self.assertIsNone(alembic.get_main_option("script_location")) + self.assertIsNone(alembic.get_main_option("version_locations")) + + def test_configured(self): + self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic") + self.config.setdefault( + "alembic.version_locations", "wuttjamaican.db:alembic/versions" + ) + + alembic = mod.make_alembic_config(self.config) + self.assertIsInstance(alembic, AlembicConfig) + self.assertEqual( + alembic.get_main_option("script_location"), "wuttjamaican.db:alembic" + ) + self.assertEqual( + alembic.get_main_option("version_locations"), + "wuttjamaican.db:alembic/versions", + ) + + class TestGetAlembicScriptdir(ConfigTestCase): + + def test_basic(self): + self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic") + self.config.setdefault( + "alembic.version_locations", "wuttjamaican.db:alembic/versions" + ) + + # can provide alembic config + alembic = mod.make_alembic_config(self.config) + script = mod.get_alembic_scriptdir(self.config, alembic) + self.assertIsInstance(script, ScriptDirectory) + + # but also can omit it + script = mod.get_alembic_scriptdir(self.config) + self.assertIsInstance(script, ScriptDirectory) + + class TestCheckAlembicCurrent(DataTestCase): + + def make_config(self, **kwargs): + sqlite_path = self.write_file("test.sqlite", "") + self.sqlite_engine_url = f"sqlite:///{sqlite_path}" + + config_path = self.write_file( + "test.ini", + f""" +[wutta.db] +default.url = {self.sqlite_engine_url} + +[alembic] +script_location = wuttjamaican.db:alembic +version_locations = wuttjamaican.db:alembic/versions +""", + ) + + return super().make_config([config_path], **kwargs) + + def test_basic(self): + alembic = mod.make_alembic_config(self.config) + self.assertIsNotNone(alembic.get_main_option("script_location")) + self.assertIsNotNone(alembic.get_main_option("version_locations")) + + # false by default, since tests use MetaData.create_all() + # instead of migrations for setup + self.assertFalse(mod.check_alembic_current(self.config, alembic)) + + # and to further prove the point, alembic_version table is missing + self.assertEqual( + self.session.execute(sa.text("select count(*) from person")).scalar(), + 0, + ) + self.assertRaises( + sa.exc.OperationalError, + self.session.execute, + sa.text("select count(*) from alembic_version"), + ) + + # but we can 'stamp' the DB to declare its current revision + alembic_command.stamp(alembic, "heads") + + # now the alembic_version table exists + self.assertEqual( + self.session.execute( + sa.text("select count(*) from alembic_version") + ).scalar(), + 1, + ) + + # and now Alembic knows we are current + self.assertTrue(mod.check_alembic_current(self.config, alembic))