feat: add support for --runas CLI param, to set versioning authorship

only relevant if Wutta-Continuum is enabled
This commit is contained in:
Lance Edgar 2025-12-29 11:10:57 -06:00
parent c6d1822f3b
commit 6ee008e169
8 changed files with 103 additions and 21 deletions

View file

@ -102,7 +102,7 @@ class ImportCommandHandler(GenericHandler):
elif key: elif key:
self.import_handler = self.app.get_import_handler(key, require=True) self.import_handler = self.app.get_import_handler(key, require=True)
def run(self, params, progress=None): # pylint: disable=unused-argument def run(self, ctx, progress=None): # pylint: disable=unused-argument
""" """
Run the import/export job(s) based on command line params. Run the import/export job(s) based on command line params.
@ -113,20 +113,27 @@ class ImportCommandHandler(GenericHandler):
Unless ``--list-models`` was specified on the command line in Unless ``--list-models`` was specified on the command line in
which case we do :meth:`list_models()` instead. which case we do :meth:`list_models()` instead.
:param params: Dict of params from command line. This must :param ctx: :class:`typer.Context` instance.
include a ``'models'`` key, the rest are optional.
:param progress: Optional progress indicator factory. :param progress: Optional progress indicator factory.
""" """
# maybe just list models and bail # maybe just list models and bail
if params.get("list_models"): if ctx.params.get("list_models"):
self.list_models(params) self.list_models(ctx.params)
return return
# otherwise process some data # otherwise we'll process some data
log.debug("using handler: %s", self.import_handler.get_spec()) log.debug("using handler: %s", self.import_handler.get_spec())
kw = dict(params)
# all params from caller will be passed along
kw = dict(ctx.params)
# runas user also, but it comes from root/parent command
if username := ctx.parent.params.get("runas_username"):
kw["runas_username"] = username
# sort out which models to process
models = kw.pop("models") models = kw.pop("models")
if not models: if not models:
models = list(self.import_handler.importers) models = list(self.import_handler.importers)
@ -136,6 +143,8 @@ class ImportCommandHandler(GenericHandler):
self.import_handler.get_title(), self.import_handler.get_title(),
", ".join(models), ", ".join(models),
) )
# process data
log.debug("params are: %s", kw) log.debug("params are: %s", kw)
self.import_handler.process_data(*models, **kw) self.import_handler.process_data(*models, **kw)

View file

@ -39,4 +39,4 @@ def import_csv(ctx: typer.Context, **kwargs): # pylint: disable=unused-argument
""" """
config = ctx.parent.wutta_config config = ctx.parent.wutta_config
handler = ImportCommandHandler(config, key="import.to_wutta.from_csv") handler = ImportCommandHandler(config, key="import.to_wutta.from_csv")
handler.run(ctx.params) handler.run(ctx)

View file

@ -70,4 +70,4 @@ def import_versions( # pylint: disable=unused-argument
sys.exit(1) sys.exit(1)
handler = ImportCommandHandler(config, key="import.to_versions.from_wutta") handler = ImportCommandHandler(config, key="import.to_versions.from_wutta")
handler.run(ctx.params) handler.run(ctx)

View file

@ -166,6 +166,12 @@ class ImportHandler(GenericHandler): # pylint: disable=too-many-public-methods
See also :attr:`warnings`. See also :attr:`warnings`.
""" """
runas_username = None
"""
Username responsible for running the import/export job. This is
mostly used for Continuum versioning.
"""
importers = None importers = None
""" """
This should be a dict of all importer/exporter classes available This should be a dict of all importer/exporter classes available
@ -416,6 +422,9 @@ class ImportHandler(GenericHandler): # pylint: disable=too-many-public-methods
if "warnings_max_diffs" in kwargs: if "warnings_max_diffs" in kwargs:
self.warnings_max_diffs = kwargs.pop("warnings_max_diffs") self.warnings_max_diffs = kwargs.pop("warnings_max_diffs")
if "runas_username" in kwargs:
self.runas_username = kwargs.pop("runas_username")
return kwargs return kwargs
def begin_transaction(self): def begin_transaction(self):
@ -946,11 +955,29 @@ class ToWuttaHandler(ToSqlalchemyHandler):
def make_target_session(self): def make_target_session(self):
""" """
Call This creates a typical :term:`db session` for the app by
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_session()` calling
and return the result. :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_session()`.
If :attr:`~ImportHandler.runas_username` is set, the
responsible user (``continuum_user_id``) will be set for the
new session as well. This info is only used if the
Wutta-Continuum versioning feature is enabled.
:returns: :class:`~wuttjamaican:wuttjamaican.db.sess.Session` :returns: :class:`~wuttjamaican:wuttjamaican.db.sess.Session`
instance. instance.
""" """
return self.app.make_session() session = self.app.make_session()
if self.runas_username:
model = self.app.model
if user := (
session.query(model.User)
.filter_by(username=self.runas_username)
.first()
):
session.info["continuum_user_id"] = user.uuid
else:
log.warning("runas username not found: %s", self.runas_username)
return session

View file

@ -2,7 +2,7 @@
import inspect import inspect
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch, Mock
from wuttasync.cli import base as mod from wuttasync.cli import base as mod
from wuttjamaican.testing import DataTestCase from wuttjamaican.testing import DataTestCase
@ -44,12 +44,20 @@ class TestImportCommandHandler(DataTestCase):
) )
with patch.object(handler, "list_models") as list_models: with patch.object(handler, "list_models") as list_models:
handler.run({"list_models": True}) ctx = Mock(params={"list_models": True})
list_models.assert_called_once_with({"list_models": True}) handler.run(ctx)
list_models.assert_called_once_with(ctx.params)
class Object:
def __init__(self, **kw):
self.__dict__.update(kw)
with patch.object(handler, "import_handler") as import_handler: with patch.object(handler, "import_handler") as import_handler:
handler.run({"models": []}) parent = Mock(params={"runas_username": "fred"})
import_handler.process_data.assert_called_once_with() # TODO: why can't we just use Mock here? the parent attr is problematic
ctx = Object(params={"models": []}, parent=parent)
handler.run(ctx)
import_handler.process_data.assert_called_once_with(runas_username="fred")
def test_list_models(self): def test_list_models(self):
handler = self.make_handler( handler = self.make_handler(

View file

@ -19,4 +19,4 @@ class TestImportCsv(TestCase):
ctx = MagicMock(params=params) ctx = MagicMock(params=params)
with patch.object(ImportCommandHandler, "run") as run: with patch.object(ImportCommandHandler, "run") as run:
mod.import_csv(ctx) mod.import_csv(ctx)
run.assert_called_once_with(params) run.assert_called_once_with(ctx)

View file

@ -19,4 +19,4 @@ class TestImportCsv(TestCase):
ctx = MagicMock(params=params) ctx = MagicMock(params=params)
with patch.object(ImportCommandHandler, "run") as run: with patch.object(ImportCommandHandler, "run") as run:
mod.import_versions(ctx) mod.import_versions(ctx)
run.assert_called_once_with(params) run.assert_called_once_with(ctx)

View file

@ -190,6 +190,14 @@ class TestImportHandler(DataTestCase):
self.assertNotIn("warnings_max_diffs", kw) self.assertNotIn("warnings_max_diffs", kw)
self.assertEqual(handler.warnings_max_diffs, 30) self.assertEqual(handler.warnings_max_diffs, 30)
# runas_username (consumed)
self.assertIsNone(handler.runas_username)
kw["runas_username"] = "fred"
result = handler.consume_kwargs(kw)
self.assertIs(result, kw)
self.assertNotIn("runas_username", kw)
self.assertEqual(handler.runas_username, "fred")
def test_define_importers(self): def test_define_importers(self):
handler = self.make_handler() handler = self.make_handler()
importers = handler.define_importers() importers = handler.define_importers()
@ -490,11 +498,41 @@ class TestToWuttaHandler(DataTestCase):
self.assertEqual(handler.get_target_title(), "what_about_this") self.assertEqual(handler.get_target_title(), "what_about_this")
def test_make_target_session(self): def test_make_target_session(self):
model = self.app.model
handler = self.make_handler() handler = self.make_handler()
# makes "new" (mocked in our case) app session fred = model.User(username="fred")
self.session.add(fred)
self.session.commit()
# makes "new" (mocked in our case) app session, with no runas
# username set by default
with patch.object(self.app, "make_session") as make_session: with patch.object(self.app, "make_session") as make_session:
make_session.return_value = self.session make_session.return_value = self.session
session = handler.make_target_session() session = handler.make_target_session()
make_session.assert_called_once_with() make_session.assert_called_once_with()
self.assertIs(session, self.session) self.assertIs(session, self.session)
self.assertNotIn("continuum_user_id", session.info)
self.assertNotIn("continuum_user_id", self.session.info)
# runas user also should not be set, if username is not valid
handler.runas_username = "freddie"
with patch.object(self.app, "make_session") as make_session:
make_session.return_value = self.session
session = handler.make_target_session()
make_session.assert_called_once_with()
self.assertIs(session, self.session)
self.assertNotIn("continuum_user_id", session.info)
self.assertNotIn("continuum_user_id", self.session.info)
# this time we should have runas user properly set
handler.runas_username = "fred"
with patch.object(self.app, "make_session") as make_session:
make_session.return_value = self.session
session = handler.make_target_session()
make_session.assert_called_once_with()
self.assertIs(session, self.session)
self.assertIn("continuum_user_id", session.info)
self.assertEqual(session.info["continuum_user_id"], fred.uuid)
self.assertIn("continuum_user_id", self.session.info)
self.assertEqual(self.session.info["continuum_user_id"], fred.uuid)