From c6d1822f3b75656c061f676bde5230943663a596 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Dec 2025 10:46:51 -0600 Subject: [PATCH 1/3] fix: accept either `--recip` or `--recips` param for import commands --- src/wuttasync/cli/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wuttasync/cli/base.py b/src/wuttasync/cli/base.py index 56d6421..cebdff3 100644 --- a/src/wuttasync/cli/base.py +++ b/src/wuttasync/cli/base.py @@ -248,7 +248,9 @@ def import_command_template( # pylint: disable=unused-argument,too-many-argumen warnings_recipients: Annotated[ str, typer.Option( - "--recip", help="Override the recipient(s) for diff warning email." + "--recip", + "--recips", + help="Override the recipient(s) for diff warning email.", ), ] = None, warnings_max_diffs: Annotated[ From 6ee008e1698f0540cf02fb22e6c899d0c8aff4b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Dec 2025 11:10:57 -0600 Subject: [PATCH 2/3] 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) From e39789009828b398a47d58d783dfdfd11e388967 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Dec 2025 12:49:39 -0600 Subject: [PATCH 3/3] feat: add support for `--comment` CLI param, to set versioning comment only relevant if Wutta-Continuum is enabled --- src/wuttasync/cli/base.py | 4 +++- src/wuttasync/cli/import_versions.py | 9 +-------- src/wuttasync/importing/handlers.py | 27 ++++++++++++++++++++++++--- src/wuttasync/importing/versions.py | 11 ++--------- tests/cli/test_base.py | 12 ++++++++++-- tests/importing/test_handlers.py | 8 ++++++++ tests/importing/test_versions.py | 16 +--------------- 7 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/wuttasync/cli/base.py b/src/wuttasync/cli/base.py index 032131a..34be0e7 100644 --- a/src/wuttasync/cli/base.py +++ b/src/wuttasync/cli/base.py @@ -129,9 +129,11 @@ class ImportCommandHandler(GenericHandler): # all params from caller will be passed along kw = dict(ctx.params) - # runas user also, but it comes from root/parent command + # runas user and comment also, but they come from root command if username := ctx.parent.params.get("runas_username"): kw["runas_username"] = username + if comment := ctx.parent.params.get("comment"): + kw["transaction_comment"] = comment # sort out which models to process models = kw.pop("models") diff --git a/src/wuttasync/cli/import_versions.py b/src/wuttasync/cli/import_versions.py index 11c9863..0057e88 100644 --- a/src/wuttasync/cli/import_versions.py +++ b/src/wuttasync/cli/import_versions.py @@ -37,14 +37,7 @@ from .base import import_command, ImportCommandHandler @wutta_typer.command() @import_command -def import_versions( # pylint: disable=unused-argument - ctx: typer.Context, - comment: Annotated[ - str, - typer.Option("--comment", "-m", help="Comment to set on the transaction."), - ] = "import catch-up versions", - **kwargs, -): +def import_versions(ctx: typer.Context, **kwargs): # pylint: disable=unused-argument """ Import latest data to version tables, for Wutta DB """ diff --git a/src/wuttasync/importing/handlers.py b/src/wuttasync/importing/handlers.py index 6ae09ba..384fbd3 100644 --- a/src/wuttasync/importing/handlers.py +++ b/src/wuttasync/importing/handlers.py @@ -172,6 +172,12 @@ class ImportHandler(GenericHandler): # pylint: disable=too-many-public-methods mostly used for Continuum versioning. """ + transaction_comment = None + """ + Optional comment to apply to the transaction, where applicable. + This is mostly used for Continuum versioning. + """ + importers = None """ This should be a dict of all importer/exporter classes available @@ -425,6 +431,9 @@ class ImportHandler(GenericHandler): # pylint: disable=too-many-public-methods if "runas_username" in kwargs: self.runas_username = kwargs.pop("runas_username") + if "transaction_comment" in kwargs: + self.transaction_comment = kwargs.pop("transaction_comment") + return kwargs def begin_transaction(self): @@ -959,18 +968,26 @@ class ToWuttaHandler(ToSqlalchemyHandler): calling :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_session()`. + It then may "customize" the session slightly. These + customizations only are relevant if Wutta-Continuum versioning + is enabled: + 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. + new session as well. + + Similarly, if :attr:`~ImportHandler.transaction_comment` is + set, it (``continuum_comment``) will also be set for the new + session. :returns: :class:`~wuttjamaican:wuttjamaican.db.sess.Session` instance. """ + model = self.app.model session = self.app.make_session() + # set runas user in case continuum versioning is enabled if self.runas_username: - model = self.app.model if user := ( session.query(model.User) .filter_by(username=self.runas_username) @@ -980,4 +997,8 @@ class ToWuttaHandler(ToSqlalchemyHandler): else: log.warning("runas username not found: %s", self.runas_username) + # set comment in case continuum versioning is enabled + if self.transaction_comment: + session.info["continuum_comment"] = self.transaction_comment + return session diff --git a/src/wuttasync/importing/versions.py b/src/wuttasync/importing/versions.py index cda77c9..d558c36 100644 --- a/src/wuttasync/importing/versions.py +++ b/src/wuttasync/importing/versions.py @@ -92,13 +92,6 @@ class FromWuttaToVersions(FromWuttaHandler, ToWuttaHandler): See also :attr:`continuum_uow`. """ - continuum_comment = None - - def consume_kwargs(self, kwargs): - kwargs = super().consume_kwargs(kwargs) - self.continuum_comment = kwargs.pop("comment", None) - return kwargs - def begin_target_transaction(self): # pylint: disable=line-too-long """ @@ -128,8 +121,8 @@ class FromWuttaToVersions(FromWuttaHandler, ToWuttaHandler): self.continuum_txn = self.continuum_uow.create_transaction(self.target_session) - if self.continuum_comment: - self.continuum_txn.meta = {"comment": self.continuum_comment} + if self.transaction_comment: + self.continuum_txn.meta = {"comment": self.transaction_comment} def get_importer_kwargs(self, key, **kwargs): """ diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py index 367eaea..2370bdd 100644 --- a/tests/cli/test_base.py +++ b/tests/cli/test_base.py @@ -53,11 +53,19 @@ class TestImportCommandHandler(DataTestCase): self.__dict__.update(kw) with patch.object(handler, "import_handler") as import_handler: - parent = Mock(params={"runas_username": "fred"}) + parent = Mock( + params={ + "runas_username": "fred", + "comment": "hello world", + } + ) # 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") + import_handler.process_data.assert_called_once_with( + runas_username="fred", + transaction_comment="hello world", + ) def test_list_models(self): handler = self.make_handler( diff --git a/tests/importing/test_handlers.py b/tests/importing/test_handlers.py index 2bcbfbb..21fcaeb 100644 --- a/tests/importing/test_handlers.py +++ b/tests/importing/test_handlers.py @@ -198,6 +198,14 @@ class TestImportHandler(DataTestCase): self.assertNotIn("runas_username", kw) self.assertEqual(handler.runas_username, "fred") + # transaction_comment (consumed) + self.assertIsNone(handler.transaction_comment) + kw["transaction_comment"] = "hello world" + result = handler.consume_kwargs(kw) + self.assertIs(result, kw) + self.assertNotIn("transaction_comment", kw) + self.assertEqual(handler.transaction_comment, "hello world") + def test_define_importers(self): handler = self.make_handler() importers = handler.define_importers() diff --git a/tests/importing/test_versions.py b/tests/importing/test_versions.py index 2cd4ec0..eea6171 100644 --- a/tests/importing/test_versions.py +++ b/tests/importing/test_versions.py @@ -14,20 +14,6 @@ class TestFromWuttaToVersions(VersionTestCase): def make_handler(self, **kwargs): return mod.FromWuttaToVersions(self.config, **kwargs) - def test_consume_kwargs(self): - - # no comment by default - handler = self.make_handler() - kw = handler.consume_kwargs({}) - self.assertEqual(kw, {}) - self.assertIsNone(handler.continuum_comment) - - # but can provide one - handler = self.make_handler() - kw = handler.consume_kwargs({"comment": "yeehaw"}) - self.assertEqual(kw, {}) - self.assertEqual(handler.continuum_comment, "yeehaw") - def test_begin_target_transaction(self): model = self.app.model txncls = continuum.transaction_class(model.User) @@ -44,7 +30,7 @@ class TestFromWuttaToVersions(VersionTestCase): # with comment handler = self.make_handler() - handler.continuum_comment = "yeehaw" + handler.transaction_comment = "yeehaw" handler.begin_target_transaction() self.assertIn("comment", handler.continuum_txn.meta) self.assertEqual(handler.continuum_txn.meta["comment"], "yeehaw")