diff --git a/CHANGELOG.md b/CHANGELOG.md index 6966a6a..974176f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.28.0 (2025-12-28) + +### Feat + +- add alembic config/utility functions, for migrations admin + +### Fix + +- add empty migration script, to avoid test problems +- show deprecation warnings by default for 'wutt*' packages + ## v0.27.1 (2025-12-21) ### Fix diff --git a/docs/narr/install/quickstart.rst b/docs/narr/install/quickstart.rst index 4679f88..06fea07 100644 --- a/docs/narr/install/quickstart.rst +++ b/docs/narr/install/quickstart.rst @@ -116,8 +116,7 @@ invoke other app/handler logic:: # invoke secondary handler to make new user account auth = app.get_auth_handler() user = auth.make_user(session=session, username='barney') - - # commit changes to DB + assert isinstance(user, model.User) session.add(user) session.commit() diff --git a/pyproject.toml b/pyproject.toml index 2cf6cf0..bd0d5df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.27.1" +version = "0.28.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index c799da6..9a723d9 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -31,6 +31,7 @@ import logging.config import os import sys import tempfile +import warnings import config as configuration @@ -1020,41 +1021,54 @@ def make_config( # pylint: disable=too-many-arguments,too-many-positional-argum :returns: The new config object. """ - # collect file paths - files = get_config_paths( - files=files, - plus_files=plus_files, - appname=appname, - env_files_name=env_files_name, - env_plus_files_name=env_plus_files_name, - env=env, - default_files=default_files, - winsvc=winsvc, - ) - # make config object - if not factory: - factory = WuttaConfig - config = factory(files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs) + # nb. always show deprecation warnings when making config + with warnings.catch_warnings(): + warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt") - # maybe extend config object - if extend: - if not extension_entry_points: - # nb. must not use appname here, entry points must be - # consistent regardless of appname - extension_entry_points = "wutta.config.extensions" + # collect file paths + files = get_config_paths( + files=files, + plus_files=plus_files, + appname=appname, + env_files_name=env_files_name, + env_plus_files_name=env_plus_files_name, + env=env, + default_files=default_files, + winsvc=winsvc, + ) - # apply all registered extensions - # TODO: maybe let config disable some extensions? - extensions = load_entry_points(extension_entry_points) - extensions = [ext() for ext in extensions.values()] - for extension in extensions: - log.debug("applying config extension: %s", extension.key) - extension.configure(config) + # make config object + if not factory: + factory = WuttaConfig + config = factory( + files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs + ) - # let extensions run startup hooks if needed - for extension in extensions: - extension.startup(config) + # maybe extend config object + if extend: + if not extension_entry_points: + # nb. must not use appname here, entry points must be + # consistent regardless of appname + extension_entry_points = "wutta.config.extensions" + + # apply all registered extensions + # TODO: maybe let config disable some extensions? + extensions = load_entry_points(extension_entry_points) + extensions = [ext() for ext in extensions.values()] + for extension in extensions: + log.debug("applying config extension: %s", extension.key) + extension.configure(config) + + # let extensions run startup hooks if needed + for extension in extensions: + extension.startup(config) + + # maybe show deprecation warnings from now on + if config.get_bool( + f"{config.appname}.show_deprecation_warnings", usedb=False, default=True + ): + warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt") return config diff --git a/src/wuttjamaican/db/alembic/versions/4d3696b894d5_empty_migration.py b/src/wuttjamaican/db/alembic/versions/4d3696b894d5_empty_migration.py new file mode 100644 index 0000000..b5f688d --- /dev/null +++ b/src/wuttjamaican/db/alembic/versions/4d3696b894d5_empty_migration.py @@ -0,0 +1,39 @@ +"""empty migration + +Revision ID: 4d3696b894d5 +Revises: b59a34266288 +Create Date: 2025-12-28 13:56:20.900043 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "4d3696b894d5" +down_revision: Union[str, None] = "b59a34266288" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# NOTE: this empty revision exists simply to ensure that its down +# revision (b59a34266288) is not the branch head. and that is needed +# because some of the tests in wuttaweb now run commands like these: +# +# alembic downgrade wutta@-1 +# alembic upgrade heads +# +# which is actually fine for postgres but not so for sqlite, due to +# the particular contents of the b59a34266288 revision. + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass 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))