From 4db3fa5962f3bf99fd2eaa27ee193b6f3cead2a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 21 Oct 2025 13:32:35 -0500 Subject: [PATCH] fix: add util module, w/ `model_transaction_query()` just a basic implementation, will have to improve later --- docs/api/wutta_continuum.testing.rst | 6 ++ docs/api/wutta_continuum.util.rst | 6 ++ docs/conf.py | 1 + docs/index.rst | 2 + src/wutta_continuum/conf.py | 36 +++++++++-- src/wutta_continuum/testing.py | 92 ++++++++++++++++++++++++++++ src/wutta_continuum/util.py | 85 +++++++++++++++++++++++++ tests/test_conf.py | 26 +++++--- tests/test_util.py | 40 ++++++++++++ 9 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 docs/api/wutta_continuum.testing.rst create mode 100644 docs/api/wutta_continuum.util.rst create mode 100644 src/wutta_continuum/testing.py create mode 100644 src/wutta_continuum/util.py create mode 100644 tests/test_util.py diff --git a/docs/api/wutta_continuum.testing.rst b/docs/api/wutta_continuum.testing.rst new file mode 100644 index 0000000..4afca16 --- /dev/null +++ b/docs/api/wutta_continuum.testing.rst @@ -0,0 +1,6 @@ + +``wutta_continuum.testing`` +=========================== + +.. automodule:: wutta_continuum.testing + :members: diff --git a/docs/api/wutta_continuum.util.rst b/docs/api/wutta_continuum.util.rst new file mode 100644 index 0000000..c337ddb --- /dev/null +++ b/docs/api/wutta_continuum.util.rst @@ -0,0 +1,6 @@ + +``wutta_continuum.util`` +======================== + +.. automodule:: wutta_continuum.util + :members: diff --git a/docs/conf.py b/docs/conf.py index cf8790a..330e71f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,6 +27,7 @@ templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] intersphinx_mapping = { + "sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None), "sqlalchemy-continuum": ( "https://sqlalchemy-continuum.readthedocs.io/en/latest/", None, diff --git a/docs/index.rst b/docs/index.rst index b4add5a..a8f656f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,3 +29,5 @@ This package adds data versioning/history for `WuttJamaican`_, using api/wutta_continuum api/wutta_continuum.app api/wutta_continuum.conf + api/wutta_continuum.testing + api/wutta_continuum.util diff --git a/src/wutta_continuum/conf.py b/src/wutta_continuum/conf.py index dc3f476..1cb3bd7 100644 --- a/src/wutta_continuum/conf.py +++ b/src/wutta_continuum/conf.py @@ -45,7 +45,28 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension): key = "wutta_continuum" def startup(self, config): # pylint: disable=empty-docstring - """ """ + """ + Perform final configuration setup for app startup. + + This will do nothing at all, unless config enables the + versioning feature. This must be done in config file and not + in DB settings table: + + .. code-block:: ini + + [wutta_continuum] + enable_versioning = true + + Once enabled, this method will configure the integration, via + these steps: + + 1. call :func:`sqlalchemy-continuum:sqlalchemy_continuum.make_versioned()` + 2. call :meth:`wuttjamaican:wuttjamaican.app.AppHandler.get_model()` + 3. call :func:`sqlalchemy:sqlalchemy.orm.configure_mappers()` + + For more about SQLAlchemy-Continuum see + :doc:`sqlalchemy-continuum:intro`. + """ # only do this if config enables it if not config.get_bool( "wutta_continuum.enable_versioning", usedb=False, default=False @@ -60,14 +81,17 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension): ) plugin = load_object(spec) - # tell sqlalchemy-continuum to do its thing + app = config.get_app() + if "model" in app.__dict__: + raise RuntimeError("something not right, app already has model") + + # let sqlalchemy-continuum do its thing make_versioned(plugins=[plugin()]) - # nb. must load the model before configuring mappers - app = config.get_app() - model = app.model # pylint: disable=unused-variable + # must load model *between* prev and next calls + app.get_model() - # tell sqlalchemy to do its thing + # let sqlalchemy do its thing configure_mappers() diff --git a/src/wutta_continuum/testing.py b/src/wutta_continuum/testing.py new file mode 100644 index 0000000..c9229d3 --- /dev/null +++ b/src/wutta_continuum/testing.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Testing utilities +""" + +import sys + +import sqlalchemy_continuum as continuum + +from wuttjamaican.testing import DataTestCase + +from wutta_continuum.conf import WuttaContinuumConfigExtension + + +class VersionTestCase(DataTestCase): + """ + Base class for test suites requiring the SQLAlchemy-Continuum + versioning feature. + + This inherits from + :class:`~wuttjamaican:wuttjamaican.testing.DataTestCase`. + """ + + def setUp(self): + self.setup_versioning() + + def setup_versioning(self): + """ + Do setup tasks relating to this class, as well as its parent(s): + + * call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.setup_db()` + + * this will in turn call :meth:`make_config()` + """ + self.setup_db() + + def tearDown(self): + self.teardown_versioning() + + def teardown_versioning(self): + """ + Do teardown tasks relating to this class, as well as its parent(s): + + * call :func:`sqlalchemy-continuum:sqlalchemy_continuum.remove_versioning()` + * call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.teardown_db()` + """ + continuum.remove_versioning() + continuum.versioning_manager.transaction_cls = continuum.TransactionFactory() + self.teardown_db() + + def make_config(self, **kwargs): + """ + Make and customize the config object. + + We override this to explicitly enable the versioning feature. + """ + config = super().make_config(**kwargs) + config.setdefault("wutta_continuum.enable_versioning", "true") + + # nb. must purge model classes from sys.modules, so they will + # be reloaded and sqlalchemy-continuum can reconfigure + if "wuttjamaican.db.model" in sys.modules: + del sys.modules["wuttjamaican.db.model.batch"] + del sys.modules["wuttjamaican.db.model.upgrades"] + del sys.modules["wuttjamaican.db.model.auth"] + del sys.modules["wuttjamaican.db.model.base"] + del sys.modules["wuttjamaican.db.model"] + + ext = WuttaContinuumConfigExtension() + ext.startup(config) + return config diff --git a/src/wutta_continuum/util.py b/src/wutta_continuum/util.py new file mode 100644 index 0000000..4ca64ec --- /dev/null +++ b/src/wutta_continuum/util.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +SQLAlchemy-Continuum utilities +""" + +import sqlalchemy as sa +from sqlalchemy import orm +import sqlalchemy_continuum as continuum + + +OPERATION_TYPES = { + continuum.Operation.INSERT: "INSERT", + continuum.Operation.UPDATE: "UPDATE", + continuum.Operation.DELETE: "DELETE", +} + + +def render_operation_type(operation_type): + """ + Render a SQLAlchemy-Continuum ``operation_type`` from a version + record, for display to user. + + :param operation_type: Value of same name from a version record. + Must be one of: + + * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.INSERT` + * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.UPDATE` + * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.DELETE` + + :returns: Display name for the operation type, as string. + """ + return OPERATION_TYPES[operation_type] + + +def model_transaction_query(instance, session=None, model_class=None): + """ + Make a query capable of finding all SQLAlchemy-Continuum + ``transaction`` records associated with the given model instance. + + :param instance: Instance of a versioned :term:`data model`. + + :param session: Optional :term:`db session` to use for the query. + If not specified, will be obtained from the ``instance``. + + :param model_class: Optional :term:`data model` class to query. + If not specified, will be obtained from the ``instance``. + + :returns: SQLAlchemy query object. Note that it will *not* have an + ``ORDER BY`` clause yet. + """ + if not session: + session = orm.object_session(instance) + if not model_class: + model_class = type(instance) + + txncls = continuum.transaction_class(model_class) + vercls = continuum.version_class(model_class) + + query = session.query(txncls).join( + vercls, + sa.and_(vercls.uuid == instance.uuid, vercls.transaction_id == txncls.id), + ) + + return query diff --git a/tests/test_conf.py b/tests/test_conf.py index 22c2873..1ea5d44 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -4,33 +4,45 @@ import socket from unittest.mock import patch -from wuttjamaican.testing import DataTestCase +from wuttjamaican.testing import ConfigTestCase, DataTestCase from wutta_continuum import conf as mod -class TestWuttaContinuumConfigExtension(DataTestCase): +class TestWuttaContinuumConfigExtension(ConfigTestCase): def make_extension(self): return mod.WuttaContinuumConfigExtension() - def test_startup(self): + def test_startup_without_versioning(self): ext = self.make_extension() - with patch.object(mod, "make_versioned") as make_versioned: with patch.object(mod, "configure_mappers") as configure_mappers: - - # nothing happens by default ext.startup(self.config) make_versioned.assert_not_called() configure_mappers.assert_not_called() - # but will if we enable it in config + def test_startup_with_versioning(self): + ext = self.make_extension() + with patch.object(mod, "make_versioned") as make_versioned: + with patch.object(mod, "configure_mappers") as configure_mappers: self.config.setdefault("wutta_continuum.enable_versioning", "true") ext.startup(self.config) make_versioned.assert_called_once() configure_mappers.assert_called_once_with() + def test_startup_with_error(self): + ext = self.make_extension() + with patch.object(mod, "make_versioned") as make_versioned: + with patch.object(mod, "configure_mappers") as configure_mappers: + self.config.setdefault("wutta_continuum.enable_versioning", "true") + # nb. it is an error for the model to be loaded prior to + # calling make_versioned() for sqlalchemy-continuum + self.app.get_model() + self.assertRaises(RuntimeError, ext.startup, self.config) + make_versioned.assert_not_called() + configure_mappers.assert_not_called() + class TestWuttaContinuumPlugin(DataTestCase): diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..944c861 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +import sqlalchemy_continuum as continuum + +from wutta_continuum import util as mod +from wutta_continuum.testing import VersionTestCase + + +class TestRenderOperationType(TestCase): + + def test_basic(self): + self.assertEqual( + mod.render_operation_type(continuum.Operation.INSERT), "INSERT" + ) + self.assertEqual( + mod.render_operation_type(continuum.Operation.UPDATE), "UPDATE" + ) + self.assertEqual( + mod.render_operation_type(continuum.Operation.DELETE), "DELETE" + ) + + +class TestModelTransactionQuery(VersionTestCase): + + def test_basic(self): + model = self.app.model + + user = model.User(username="fred") + self.session.add(user) + self.session.commit() + + query = mod.model_transaction_query(user) + self.assertEqual(query.count(), 1) + txn = query.one() + + UserVersion = continuum.version_class(model.User) + version = self.session.query(UserVersion).one() + self.assertIs(version.transaction, txn)