feat: add alembic config/utility functions, for migrations admin
wuttaweb will have some admin tools exposed soon hopefully
This commit is contained in:
parent
5a2748c775
commit
189a53c82a
3 changed files with 216 additions and 8 deletions
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue