diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0333db3..ab6d44e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to Wutta-Continuum 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.2.1 (2025-10-29)
+
+### Fix
+
+- add util module, w/ `model_transaction_query()`
+- refactor some more for tests + pylint
+- format all code with black
+
## v0.2.0 (2024-12-07)
### Feat
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/pyproject.toml b/pyproject.toml
index 6c7f2a5..6f1fe7f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "Wutta-Continuum"
-version = "0.2.0"
+version = "0.2.1"
description = "SQLAlchemy-Continuum versioning for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
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)