3
0
Fork 0

feat: add alembic config/utility functions, for migrations admin

wuttaweb will have some admin tools exposed soon hopefully
This commit is contained in:
Lance Edgar 2025-12-21 14:29:54 -06:00
parent 5a2748c775
commit 189a53c82a
3 changed files with 216 additions and 8 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttJamaican -- Base package for Wutta Framework # WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2024 Lance Edgar # Copyright © 2023-2025 Lance Edgar
# #
# This file is part of Wutta Framework. # This file is part of Wutta Framework.
# #
@ -27,6 +27,9 @@ WuttJamaican - database configuration
from collections import OrderedDict from collections import OrderedDict
import sqlalchemy as sa 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 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) engine = sa.engine_from_config(config_dict, prefix, **kwargs)
return engine 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())

View file

@ -176,9 +176,10 @@ class ConfigTestCase(FileTestCase):
""" """
self.teardown_files() self.teardown_files()
def make_config(self, **kwargs): # pylint: disable=empty-docstring def make_config( # pylint: disable=missing-function-docstring
""" """ self, files=None, **kwargs
return WuttaConfig(**kwargs) ):
return WuttaConfig(files, **kwargs)
class DataTestCase(ConfigTestCase): class DataTestCase(ConfigTestCase):
@ -213,6 +214,8 @@ class DataTestCase(ConfigTestCase):
teardown methods, as this class handles that automatically. teardown methods, as this class handles that automatically.
""" """
sqlite_engine_url = "sqlite://"
def setUp(self): # pylint: disable=empty-docstring def setUp(self): # pylint: disable=empty-docstring
""" """ """ """
self.setup_db() self.setup_db()
@ -237,8 +240,7 @@ class DataTestCase(ConfigTestCase):
""" """
self.teardown_config() self.teardown_config()
def make_config(self, **kwargs): # pylint: disable=empty-docstring def make_config(self, files=None, **kwargs):
""" """
defaults = kwargs.setdefault("defaults", {}) defaults = kwargs.setdefault("defaults", {})
defaults.setdefault("wutta.db.default.url", "sqlite://") defaults.setdefault("wutta.db.default.url", self.sqlite_engine_url)
return super().make_config(**kwargs) return super().make_config(files, **kwargs)

View file

@ -6,13 +6,18 @@ import tempfile
from unittest import TestCase from unittest import TestCase
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import ConfigTestCase, DataTestCase
try: try:
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.pool import NullPool 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
from wuttjamaican.db import conf as mod
except ImportError: except ImportError:
pass pass
else: else:
@ -154,3 +159,111 @@ else:
} }
) )
self.assertTrue(engine.pool._pre_ping) 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))