From 6ee008e1698f0540cf02fb22e6c899d0c8aff4b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Dec 2025 11:10:57 -0600 Subject: [PATCH] feat: add support for `--runas` CLI param, to set versioning authorship only relevant if Wutta-Continuum is enabled --- src/wuttasync/cli/base.py | 23 +++++++++++----- src/wuttasync/cli/import_csv.py | 2 +- src/wuttasync/cli/import_versions.py | 2 +- src/wuttasync/importing/handlers.py | 35 +++++++++++++++++++++--- tests/cli/test_base.py | 18 +++++++++---- tests/cli/test_import_csv.py | 2 +- tests/cli/test_import_versions.py | 2 +- tests/importing/test_handlers.py | 40 +++++++++++++++++++++++++++- 8 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/wuttasync/cli/base.py b/src/wuttasync/cli/base.py index cebdff3..032131a 100644 --- a/src/wuttasync/cli/base.py +++ b/src/wuttasync/cli/base.py @@ -102,7 +102,7 @@ class ImportCommandHandler(GenericHandler): elif key: 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. @@ -113,20 +113,27 @@ class ImportCommandHandler(GenericHandler): Unless ``--list-models`` was specified on the command line in which case we do :meth:`list_models()` instead. - :param params: Dict of params from command line. This must - include a ``'models'`` key, the rest are optional. + :param ctx: :class:`typer.Context` instance. :param progress: Optional progress indicator factory. """ # maybe just list models and bail - if params.get("list_models"): - self.list_models(params) + if ctx.params.get("list_models"): + self.list_models(ctx.params) return - # otherwise process some data + # otherwise we'll process some data 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") if not models: models = list(self.import_handler.importers) @@ -136,6 +143,8 @@ class ImportCommandHandler(GenericHandler): self.import_handler.get_title(), ", ".join(models), ) + + # process data log.debug("params are: %s", kw) self.import_handler.process_data(*models, **kw) diff --git a/src/wuttasync/cli/import_csv.py b/src/wuttasync/cli/import_csv.py index 4c5694a..1a55523 100644 --- a/src/wuttasync/cli/import_csv.py +++ b/src/wuttasync/cli/import_csv.py @@ -39,4 +39,4 @@ def import_csv(ctx: typer.Context, **kwargs): # pylint: disable=unused-argument """ config = ctx.parent.wutta_config handler = ImportCommandHandler(config, key="import.to_wutta.from_csv") - handler.run(ctx.params) + handler.run(ctx) diff --git a/src/wuttasync/cli/import_versions.py b/src/wuttasync/cli/import_versions.py index aa82088..11c9863 100644 --- a/src/wuttasync/cli/import_versions.py +++ b/src/wuttasync/cli/import_versions.py @@ -70,4 +70,4 @@ def import_versions( # pylint: disable=unused-argument sys.exit(1) handler = ImportCommandHandler(config, key="import.to_versions.from_wutta") - handler.run(ctx.params) + handler.run(ctx) diff --git a/src/wuttasync/importing/handlers.py b/src/wuttasync/importing/handlers.py index ac13f28..6ae09ba 100644 --- a/src/wuttasync/importing/handlers.py +++ b/src/wuttasync/importing/handlers.py @@ -166,6 +166,12 @@ class ImportHandler(GenericHandler): # pylint: disable=too-many-public-methods See also :attr:`warnings`. """ + runas_username = None + """ + Username responsible for running the import/export job. This is + mostly used for Continuum versioning. + """ + importers = None """ 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: self.warnings_max_diffs = kwargs.pop("warnings_max_diffs") + if "runas_username" in kwargs: + self.runas_username = kwargs.pop("runas_username") + return kwargs def begin_transaction(self): @@ -946,11 +955,29 @@ class ToWuttaHandler(ToSqlalchemyHandler): def make_target_session(self): """ - Call - :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_session()` - and return the result. + This creates a typical :term:`db session` for the app by + calling + :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` 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 diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py index b8fc954..367eaea 100644 --- a/tests/cli/test_base.py +++ b/tests/cli/test_base.py @@ -2,7 +2,7 @@ import inspect from unittest import TestCase -from unittest.mock import patch +from unittest.mock import patch, Mock from wuttasync.cli import base as mod from wuttjamaican.testing import DataTestCase @@ -44,12 +44,20 @@ class TestImportCommandHandler(DataTestCase): ) with patch.object(handler, "list_models") as list_models: - handler.run({"list_models": True}) - list_models.assert_called_once_with({"list_models": True}) + ctx = Mock(params={"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: - handler.run({"models": []}) - import_handler.process_data.assert_called_once_with() + parent = Mock(params={"runas_username": "fred"}) + # 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): handler = self.make_handler( diff --git a/tests/cli/test_import_csv.py b/tests/cli/test_import_csv.py index 5623176..2529380 100644 --- a/tests/cli/test_import_csv.py +++ b/tests/cli/test_import_csv.py @@ -19,4 +19,4 @@ class TestImportCsv(TestCase): ctx = MagicMock(params=params) with patch.object(ImportCommandHandler, "run") as run: mod.import_csv(ctx) - run.assert_called_once_with(params) + run.assert_called_once_with(ctx) diff --git a/tests/cli/test_import_versions.py b/tests/cli/test_import_versions.py index ea1617d..506a5e9 100644 --- a/tests/cli/test_import_versions.py +++ b/tests/cli/test_import_versions.py @@ -19,4 +19,4 @@ class TestImportCsv(TestCase): ctx = MagicMock(params=params) with patch.object(ImportCommandHandler, "run") as run: mod.import_versions(ctx) - run.assert_called_once_with(params) + run.assert_called_once_with(ctx) diff --git a/tests/importing/test_handlers.py b/tests/importing/test_handlers.py index c01b405..2bcbfbb 100644 --- a/tests/importing/test_handlers.py +++ b/tests/importing/test_handlers.py @@ -190,6 +190,14 @@ class TestImportHandler(DataTestCase): self.assertNotIn("warnings_max_diffs", kw) 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): handler = self.make_handler() importers = handler.define_importers() @@ -490,11 +498,41 @@ class TestToWuttaHandler(DataTestCase): self.assertEqual(handler.get_target_title(), "what_about_this") def test_make_target_session(self): + model = self.app.model 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: 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) + + # 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)