From ae282ab468861c816173c4c87d6090b6738d61be Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Mar 2026 17:15:34 -0500 Subject: [PATCH] feat: add support for Wutta <-> Wutta import/export --- docs/api/wuttasync.cli.export_wutta.rst | 6 + docs/api/wuttasync.cli.import_wutta.rst | 6 + docs/api/wuttasync.conf.rst | 6 + docs/index.rst | 3 + docs/narr/cli/builtin.rst | 24 ++++ pyproject.toml | 5 + src/wuttasync/app.py | 36 ++++-- src/wuttasync/cli/__init__.py | 2 + src/wuttasync/cli/base.py | 26 +++- src/wuttasync/cli/export_wutta.py | 53 +++++++++ src/wuttasync/cli/import_wutta.py | 53 +++++++++ src/wuttasync/conf.py | 50 ++++++++ src/wuttasync/emails.py | 10 +- src/wuttasync/importing/csv.py | 2 +- src/wuttasync/importing/handlers.py | 9 +- src/wuttasync/importing/versions.py | 2 +- src/wuttasync/importing/wutta.py | 150 +++++++++++++++++++++++- tests/cli/test_base.py | 36 ++++++ tests/cli/test_export_wutta.py | 23 ++++ tests/cli/test_import_wutta.py | 23 ++++ tests/importing/test_handlers.py | 11 ++ tests/importing/test_wutta.py | 131 +++++++++++++++++++++ tests/test_app.py | 20 ++++ tests/test_conf.py | 39 ++++++ tests/test_emails.py | 14 +++ 25 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 docs/api/wuttasync.cli.export_wutta.rst create mode 100644 docs/api/wuttasync.cli.import_wutta.rst create mode 100644 docs/api/wuttasync.conf.rst create mode 100644 src/wuttasync/cli/export_wutta.py create mode 100644 src/wuttasync/cli/import_wutta.py create mode 100644 src/wuttasync/conf.py create mode 100644 tests/cli/test_export_wutta.py create mode 100644 tests/cli/test_import_wutta.py create mode 100644 tests/test_conf.py diff --git a/docs/api/wuttasync.cli.export_wutta.rst b/docs/api/wuttasync.cli.export_wutta.rst new file mode 100644 index 0000000..cb3caf8 --- /dev/null +++ b/docs/api/wuttasync.cli.export_wutta.rst @@ -0,0 +1,6 @@ + +``wuttasync.cli.export_wutta`` +============================== + +.. automodule:: wuttasync.cli.export_wutta + :members: diff --git a/docs/api/wuttasync.cli.import_wutta.rst b/docs/api/wuttasync.cli.import_wutta.rst new file mode 100644 index 0000000..466a726 --- /dev/null +++ b/docs/api/wuttasync.cli.import_wutta.rst @@ -0,0 +1,6 @@ + +``wuttasync.cli.import_wutta`` +============================== + +.. automodule:: wuttasync.cli.import_wutta + :members: diff --git a/docs/api/wuttasync.conf.rst b/docs/api/wuttasync.conf.rst new file mode 100644 index 0000000..7533c9f --- /dev/null +++ b/docs/api/wuttasync.conf.rst @@ -0,0 +1,6 @@ + +``wuttasync.conf`` +================== + +.. automodule:: wuttasync.conf + :members: diff --git a/docs/index.rst b/docs/index.rst index 215e892..36d0e25 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,8 +74,11 @@ cf. :doc:`rattail-manual:data/sync/index`. api/wuttasync.cli api/wuttasync.cli.base api/wuttasync.cli.export_csv + api/wuttasync.cli.export_wutta api/wuttasync.cli.import_csv api/wuttasync.cli.import_versions + api/wuttasync.cli.import_wutta + api/wuttasync.conf api/wuttasync.emails api/wuttasync.exporting api/wuttasync.exporting.base diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst index 5cb3123..399c2d7 100644 --- a/docs/narr/cli/builtin.rst +++ b/docs/narr/cli/builtin.rst @@ -27,6 +27,18 @@ Defined in: :mod:`wuttasync.cli.export_csv` .. program-output:: wutta export-csv --help +.. _wutta-export-wutta: + +``wutta export-wutta`` +---------------------- + +Export data to another Wutta :term:`app database`, from the local one. + +Defined in: :mod:`wuttasync.cli.export_wutta` + +.. program-output:: wutta export-wutta --help + + .. _wutta-import-csv: ``wutta import-csv`` @@ -64,3 +76,15 @@ in the :term:`app model`. Defined in: :mod:`wuttasync.cli.import_versions` .. program-output:: wutta import-versions --help + + +.. _wutta-import-wutta: + +``wutta import-wutta`` +---------------------- + +Import data from another Wutta :term:`app database`, to the local one. + +Defined in: :mod:`wuttasync.cli.import_wutta` + +.. program-output:: wutta import-wutta --help diff --git a/pyproject.toml b/pyproject.toml index d3db422..8bc63a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,15 @@ tests = ["pylint", "pytest", "pytest-cov", "tox", "Wutta-Continuum>=0.3.0"] [project.entry-points."wutta.app.providers"] wuttasync = "wuttasync.app:WuttaSyncAppProvider" +[project.entry-points."wutta.config.extensions"] +"wuttasync" = "wuttasync.conf:WuttaSyncConfig" + [project.entry-points."wuttasync.importing"] "export.to_csv.from_wutta" = "wuttasync.exporting.csv:FromWuttaToCsv" +"export.to_wutta.from_wutta" = "wuttasync.importing.wutta:FromWuttaToWuttaExport" "import.to_versions.from_wutta" = "wuttasync.importing.versions:FromWuttaToVersions" "import.to_wutta.from_csv" = "wuttasync.importing.csv:FromCsvToWutta" +"import.to_wutta.from_wutta" = "wuttasync.importing.wutta:FromWuttaToWuttaImport" [project.entry-points."wutta.typer_imports"] wuttasync = "wuttasync.cli" diff --git a/src/wuttasync/app.py b/src/wuttasync/app.py index 0fa19fd..a73b26e 100644 --- a/src/wuttasync/app.py +++ b/src/wuttasync/app.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaSync -- Wutta Framework for data import/export and real-time sync -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -87,18 +87,32 @@ class WuttaSyncAppProvider(AppProvider): :returns: List of all import/export handler classes """ - # first load all "registered" Handler classes - factories = load_entry_points("wuttasync.importing", ignore_errors=True) + # first load all "registered" Handler classes. note we must + # specify lists=True since handlers from different projects + # can be registered with the same key. + factory_lists = load_entry_points( + "wuttasync.importing", lists=True, ignore_errors=True + ) # organize registered classes by spec - specs = {factory.get_spec(): factory for factory in factories.values()} + specs = {} + all_factories = [] + for factories in factory_lists.values(): + for factory in factories: + specs[factory.get_spec()] = factory + all_factories.append(factory) # many handlers may not be registered per se, but may be # designated via config. so try to include those too - for factory in factories.values(): - spec = self.get_designated_import_handler_spec(factory.get_key()) + seen = set() + for factory in all_factories: + key = factory.get_key() + if key in seen: + continue + spec = self.get_designated_import_handler_spec(key) if spec and spec not in specs: specs[spec] = self.app.load_object(spec) + seen.add(key) # flatten back to simple list of classes factories = list(specs.values()) @@ -203,22 +217,26 @@ class WuttaSyncAppProvider(AppProvider): :param require: Set this to true if you want an error raised when no handler is found. + :param \\**kwargs: Remaining kwargs are passed as-is to the + handler constructor. + :returns: The import/export handler instance. If no handler is found, then ``None`` is returned, unless ``require`` param is true, in which case error is raised. """ # first try to fetch the handler per designated spec - spec = self.get_designated_import_handler_spec(key, **kwargs) + spec = self.get_designated_import_handler_spec(key) if spec: factory = self.app.load_object(spec) - return factory(self.config) + return factory(self.config, **kwargs) # nothing was designated, so leverage logic which already # sorts out which handler is "designated" for given key designated = self.get_designated_import_handlers() for handler in designated: if handler.get_key() == key: - return handler + factory = type(handler) + return factory(self.config, **kwargs) if require: raise ValueError(f"Cannot locate import handler for key: {key}") diff --git a/src/wuttasync/cli/__init__.py b/src/wuttasync/cli/__init__.py index a3fa82b..231f072 100644 --- a/src/wuttasync/cli/__init__.py +++ b/src/wuttasync/cli/__init__.py @@ -40,5 +40,7 @@ from .base import ( # nb. must bring in all modules for discovery to work from . import export_csv +from . import export_wutta from . import import_csv from . import import_versions +from . import import_wutta diff --git a/src/wuttasync/cli/base.py b/src/wuttasync/cli/base.py index 68bb536..09db3ea 100644 --- a/src/wuttasync/cli/base.py +++ b/src/wuttasync/cli/base.py @@ -65,7 +65,13 @@ class ImportCommandHandler(GenericHandler): :param key: Optional :term:`import/export key` to use for handler lookup. Only used if ``import_handler`` param is not set. - Typical usage for custom commands will be to provide the spec:: + :param \\**kwargs: Remaining kwargs are passed as-is to the + import/export handler constructor, i.e. when making the + :attr:`import_handler`. Note that if the ``import_handler`` + *instance* is specified, these kwargs will be ignored. + + Typical usage for custom commands will be to provide the spec + (please note the *colon*):: handler = ImportCommandHandler( config, "poser.importing.foo:FromFooToPoser" @@ -81,6 +87,14 @@ class ImportCommandHandler(GenericHandler): See also :meth:`~wuttasync.app.WuttaSyncAppProvider.get_import_handler()` which does the lookup by key. + + Additional kwargs may be specified as needed. Typically these + should wind up as attributes on the import/export handler + instance:: + + handler = ImportCommandHandler( + config, "poser.importing.foo:FromFooToPoser", dbkey="remote" + ) """ import_handler = None @@ -89,20 +103,22 @@ class ImportCommandHandler(GenericHandler): invoked when command runs. See also :meth:`run()`. """ - def __init__(self, config, import_handler=None, key=None): + def __init__(self, config, import_handler=None, key=None, **kwargs): super().__init__(config) if import_handler: if isinstance(import_handler, ImportHandler): self.import_handler = import_handler elif callable(import_handler): - self.import_handler = import_handler(self.config) + self.import_handler = import_handler(self.config, **kwargs) else: # spec factory = self.app.load_object(import_handler) - self.import_handler = factory(self.config) + self.import_handler = factory(self.config, **kwargs) elif key: - self.import_handler = self.app.get_import_handler(key, require=True) + self.import_handler = self.app.get_import_handler( + key, require=True, **kwargs + ) def run(self, ctx, progress=None): # pylint: disable=unused-argument """ diff --git a/src/wuttasync/cli/export_wutta.py b/src/wuttasync/cli/export_wutta.py new file mode 100644 index 0000000..2c89b3e --- /dev/null +++ b/src/wuttasync/cli/export_wutta.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaSync -- Wutta Framework for data import/export and real-time sync +# Copyright © 2024-2026 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 . +# +################################################################################ +""" +See also: :ref:`wutta-export-wutta` +""" + +from typing_extensions import Annotated + +import typer + +from wuttjamaican.cli import wutta_typer + +from .base import import_command, ImportCommandHandler + + +@wutta_typer.command() +@import_command +def export_wutta( + ctx: typer.Context, + dbkey: Annotated[ + str, + typer.Option(help="Config key for app db engine to be used as data target."), + ] = None, + **kwargs, +): # pylint: disable=unused-argument + """ + Export data to another Wutta DB + """ + config = ctx.parent.wutta_config + handler = ImportCommandHandler( + config, key="export.to_wutta.from_wutta", dbkey=ctx.params["dbkey"] + ) + handler.run(ctx) diff --git a/src/wuttasync/cli/import_wutta.py b/src/wuttasync/cli/import_wutta.py new file mode 100644 index 0000000..02101e0 --- /dev/null +++ b/src/wuttasync/cli/import_wutta.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaSync -- Wutta Framework for data import/export and real-time sync +# Copyright © 2024-2026 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 . +# +################################################################################ +""" +See also: :ref:`wutta-import-wutta` +""" + +from typing_extensions import Annotated + +import typer + +from wuttjamaican.cli import wutta_typer + +from .base import import_command, ImportCommandHandler + + +@wutta_typer.command() +@import_command +def import_wutta( + ctx: typer.Context, + dbkey: Annotated[ + str, + typer.Option(help="Config key for app db engine to be used as data source."), + ] = None, + **kwargs, +): # pylint: disable=unused-argument + """ + Import data from another Wutta DB + """ + config = ctx.parent.wutta_config + handler = ImportCommandHandler( + config, key="import.to_wutta.from_wutta", dbkey=ctx.params["dbkey"] + ) + handler.run(ctx) diff --git a/src/wuttasync/conf.py b/src/wuttasync/conf.py new file mode 100644 index 0000000..3a980d5 --- /dev/null +++ b/src/wuttasync/conf.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaSync -- Wutta Framework for data import/export and real-time sync +# Copyright © 2024-2026 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 . +# +################################################################################ +""" +WuttaSync config extension +""" + +from wuttjamaican.conf import WuttaConfigExtension + + +class WuttaSyncConfig(WuttaConfigExtension): + """ + Config extension for WuttaSync. + + This just configures some default import/export handlers. + """ + + key = "wuttasync" + + def configure(self, config): # pylint: disable=empty-docstring + """ """ + + # default import/export handlers + config.setdefault( + "wuttasync.importing.import.to_wutta.from_wutta.default_handler", + "wuttasync.importing.wutta:FromWuttaToWuttaImport", + ) + config.setdefault( + "wuttasync.importing.export.to_wutta.from_wutta.default_handler", + "wuttasync.importing.wutta:FromWuttaToWuttaExport", + ) diff --git a/src/wuttasync/emails.py b/src/wuttasync/emails.py index b34112d..a23fd74 100644 --- a/src/wuttasync/emails.py +++ b/src/wuttasync/emails.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaSync -- Wutta Framework for data import/export and real-time sync -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -164,3 +164,11 @@ class import_to_wutta_from_csv_warning( # pylint: disable=invalid-name """ Diff warning for CSV → Wutta import. """ + + +class import_to_wutta_from_wutta_warning( # pylint: disable=invalid-name + ImportExportWarning +): + """ + Diff warning for Wutta → Wutta import. + """ diff --git a/src/wuttasync/importing/csv.py b/src/wuttasync/importing/csv.py index 60c51eb..ca39830 100644 --- a/src/wuttasync/importing/csv.py +++ b/src/wuttasync/importing/csv.py @@ -239,7 +239,7 @@ class FromCsvToSqlalchemyHandlerMixin: raise NotImplementedError # TODO: pylint (correctly) flags this as duplicate code, matching - # on the wuttasync.importing.versions module - should fix? + # on the wuttasync.importing.versions/wutta module - should fix? def define_importers(self): """ This mixin overrides typical (manual) importer definition, and diff --git a/src/wuttasync/importing/handlers.py b/src/wuttasync/importing/handlers.py index cc53bdf..47b2fbc 100644 --- a/src/wuttasync/importing/handlers.py +++ b/src/wuttasync/importing/handlers.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaSync -- Wutta Framework for data import/export and real-time sync -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -203,7 +203,12 @@ class ImportHandler( # pylint: disable=too-many-public-methods,too-many-instanc def __init__(self, config, **kwargs): """ """ - super().__init__(config, **kwargs) + super().__init__(config) + + # callers can set any attrs they want + for k, v in kwargs.items(): + setattr(self, k, v) + self.importers = self.define_importers() def __str__(self): diff --git a/src/wuttasync/importing/versions.py b/src/wuttasync/importing/versions.py index d558c36..07a03a3 100644 --- a/src/wuttasync/importing/versions.py +++ b/src/wuttasync/importing/versions.py @@ -138,7 +138,7 @@ class FromWuttaToVersions(FromWuttaHandler, ToWuttaHandler): return kwargs # TODO: pylint (correctly) flags this as duplicate code, matching - # on the wuttasync.importing.csv module - should fix? + # on the wuttasync.importing.csv/wutta module - should fix? def define_importers(self): """ This overrides typical (manual) importer definition, instead diff --git a/src/wuttasync/importing/wutta.py b/src/wuttasync/importing/wutta.py index 882f7df..cb1be5d 100644 --- a/src/wuttasync/importing/wutta.py +++ b/src/wuttasync/importing/wutta.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaSync -- Wutta Framework for data import/export and real-time sync -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -24,10 +24,156 @@ Wutta → Wutta import/export """ +from collections import OrderedDict + +from sqlalchemy_utils.functions import get_primary_keys + +from wuttjamaican.db.util import make_topo_sortkey + from .base import FromSqlalchemyMirror +from .model import ToWutta +from .handlers import FromWuttaHandler, ToWuttaHandler, Orientation class FromWuttaMirror(FromSqlalchemyMirror): # pylint: disable=abstract-method """ - Base class for Wutta -> Wutta data importers. + Base class for Wutta → Wutta data :term:`importers/exporters + `. + + This inherits from + :class:`~wuttasync.importing.base.FromSqlalchemyMirror`. """ + + +class FromWuttaToWuttaBase(FromWuttaHandler, ToWuttaHandler): + """ + Base class for Wutta → Wutta data :term:`import/export handlers + `. + + This inherits from + :class:`~wuttasync.importing.handlers.FromWuttaHandler` and + :class:`~wuttasync.importing.handlers.ToWuttaHandler`. + """ + + dbkey = None + """ + Config key for the "other" (non-local) :term:`app database`. + Depending on context this will represent either the source or + target for import/export. + """ + + def get_target_model(self): # pylint: disable=missing-function-docstring + return self.app.model + + # TODO: pylint (correctly) flags this as duplicate code, matching + # on the wuttasync.importing.csv/versions module - should fix? + def define_importers(self): + """ + This overrides typical (manual) importer definition, and + instead dynamically generates a set of importers, e.g. one per + table in the target DB. + + It does this by calling :meth:`make_importer_factory()` for + each class found in the :term:`app model`. + """ + importers = {} + model = self.get_target_model() + + # pylint: disable=duplicate-code + # mostly try to make an importer for every data model + for name in dir(model): + cls = getattr(model, name) + if ( + isinstance(cls, type) + and issubclass(cls, model.Base) + and cls is not model.Base + ): + importers[name] = self.make_importer_factory(cls, name) + + # sort importers according to schema topography + topo_sortkey = make_topo_sortkey(model) + importers = OrderedDict( + [(name, importers[name]) for name in sorted(importers, key=topo_sortkey)] + ) + + return importers + + def make_importer_factory(self, model_class, name): + """ + Generate and return a new :term:`importer` class, targeting + the given :term:`data model` class. + + The newly-created class will inherit from: + + * :class:`FromWuttaMirror` + * :class:`~wuttasync.importing.model.ToWutta` + + :param model_class: A data model class. + + :param name: The "model name" for the importer/exporter. New + class name will be based on this, so e.g. ``Widget`` model + name becomes ``WidgetImporter`` class name. + + :returns: The new class, meant to process import/export + targeting the given data model. + """ + return type( + f"{name}Importer", + (FromWuttaMirror, ToWutta), + { + "model_class": model_class, + "key": list(get_primary_keys(model_class)), + }, + ) + + +class FromWuttaToWuttaImport(FromWuttaToWuttaBase): + """ + Handler for Wutta (other) → Wutta (local) data import. + + This inherits from :class:`FromWuttaToWuttaBase`. + """ + + orientation = Orientation.IMPORT + """ """ # nb. suppress docs + + def make_source_session(self): + """ + This makes a "normal" :term:`db session`, but will use the + engine corresponding to the + :attr:`~FromWuttaToWuttaBase.dbkey`. + """ + if ( + not self.dbkey + or self.dbkey == "default" + or self.dbkey not in self.config.appdb_engines + ): + raise ValueError(f"dbkey is not valid: {self.dbkey}") + engine = self.config.appdb_engines[self.dbkey] + return self.app.make_session(bind=engine) + + +class FromWuttaToWuttaExport(FromWuttaToWuttaBase): + """ + Handler for Wutta (local) → Wutta (other) data export. + + This inherits from :class:`FromWuttaToWuttaBase`. + """ + + orientation = Orientation.EXPORT + """ """ # nb. suppress docs + + def make_target_session(self): + """ + This makes a "normal" :term:`db session`, but will use the + engine corresponding to the + :attr:`~FromWuttaToWuttaBase.dbkey`. + """ + if ( + not self.dbkey + or self.dbkey == "default" + or self.dbkey not in self.config.appdb_engines + ): + raise ValueError(f"dbkey is not valid: {self.dbkey}") + engine = self.config.appdb_engines[self.dbkey] + return self.app.make_session(bind=engine) diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py index 209dbca..52f7124 100644 --- a/tests/cli/test_base.py +++ b/tests/cli/test_base.py @@ -25,19 +25,55 @@ class TestImportCommandHandler(DataTestCase): # as spec handler = self.make_handler(import_handler=FromCsvToWutta.get_spec()) self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertFalse(hasattr(handler.import_handler, "foo")) + + # as spec, w/ kwargs + handler = self.make_handler(import_handler=FromCsvToWutta.get_spec(), foo="bar") + self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertTrue(hasattr(handler.import_handler, "foo")) + self.assertEqual(handler.import_handler.foo, "bar") # as factory handler = self.make_handler(import_handler=FromCsvToWutta) self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertFalse(hasattr(handler.import_handler, "foo")) + + # as factory, w/ kwargs + handler = self.make_handler(import_handler=FromCsvToWutta, foo="bar") + self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertTrue(hasattr(handler.import_handler, "foo")) + self.assertEqual(handler.import_handler.foo, "bar") # as instance myhandler = FromCsvToWutta(self.config) handler = self.make_handler(import_handler=myhandler) self.assertIs(handler.import_handler, myhandler) + self.assertFalse(hasattr(handler, "foo")) + self.assertFalse(hasattr(handler.import_handler, "foo")) + + # as instance, w/ kwargs (which are ignored) + myhandler = FromCsvToWutta(self.config) + handler = self.make_handler(import_handler=myhandler, foo="bar") + self.assertIs(handler.import_handler, myhandler) + self.assertFalse(hasattr(handler, "foo")) + self.assertFalse(hasattr(handler.import_handler, "foo")) # as key handler = self.make_handler(key="import.to_wutta.from_csv") self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertFalse(hasattr(handler.import_handler, "foo")) + + # as key, w/ kwargs + handler = self.make_handler(key="import.to_wutta.from_csv", foo="bar") + self.assertIsInstance(handler.import_handler, FromCsvToWutta) + self.assertFalse(hasattr(handler, "foo")) + self.assertTrue(hasattr(handler.import_handler, "foo")) + self.assertEqual(handler.import_handler.foo, "bar") def test_run(self): handler = self.make_handler( diff --git a/tests/cli/test_export_wutta.py b/tests/cli/test_export_wutta.py new file mode 100644 index 0000000..73e4ab2 --- /dev/null +++ b/tests/cli/test_export_wutta.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from wuttasync.cli import export_wutta as mod, ImportCommandHandler + + +class TestExportWutta(TestCase): + + def test_basic(self): + params = { + "dbkey": "another", + "models": [], + "create": True, + "update": True, + "delete": False, + "dry_run": True, + } + ctx = MagicMock(params=params) + with patch.object(ImportCommandHandler, "run") as run: + mod.export_wutta(ctx) + run.assert_called_once_with(ctx) diff --git a/tests/cli/test_import_wutta.py b/tests/cli/test_import_wutta.py new file mode 100644 index 0000000..3887c56 --- /dev/null +++ b/tests/cli/test_import_wutta.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from wuttasync.cli import import_wutta as mod, ImportCommandHandler + + +class TestImportWutta(TestCase): + + def test_basic(self): + params = { + "dbkey": "another", + "models": [], + "create": True, + "update": True, + "delete": False, + "dry_run": True, + } + ctx = MagicMock(params=params) + with patch.object(ImportCommandHandler, "run") as run: + mod.import_wutta(ctx) + run.assert_called_once_with(ctx) diff --git a/tests/importing/test_handlers.py b/tests/importing/test_handlers.py index 659cda1..a81a933 100644 --- a/tests/importing/test_handlers.py +++ b/tests/importing/test_handlers.py @@ -19,6 +19,17 @@ class TestImportHandler(DataTestCase): def make_handler(self, **kwargs): return mod.ImportHandler(self.config, **kwargs) + def test_constructor(self): + + # attr missing by default + handler = self.make_handler() + self.assertFalse(hasattr(handler, "some_foo_attr")) + + # but constructor can set it + handler = self.make_handler(some_foo_attr="bar") + self.assertTrue(hasattr(handler, "some_foo_attr")) + self.assertEqual(handler.some_foo_attr, "bar") + def test_str(self): handler = self.make_handler() self.assertEqual(str(handler), "None → None") diff --git a/tests/importing/test_wutta.py b/tests/importing/test_wutta.py index 1533605..cd43df0 100644 --- a/tests/importing/test_wutta.py +++ b/tests/importing/test_wutta.py @@ -1,3 +1,134 @@ # -*- coding: utf-8; -*- +from unittest.mock import patch + +import sqlalchemy as sa + +from wuttjamaican.testing import DataTestCase + from wuttasync.importing import wutta as mod +from wuttasync.importing import ToWutta + + +class TestFromWuttaMirror(DataTestCase): + + def make_importer(self, **kwargs): + return mod.FromWuttaMirror(self.config, **kwargs) + + def test_basic(self): + importer = self.make_importer() + self.assertIsInstance(importer, mod.FromWuttaMirror) + + +class TestFromWuttaToWuttaBase(DataTestCase): + + def make_handler(self, **kwargs): + return mod.FromWuttaToWuttaBase(self.config, **kwargs) + + def test_dbkey(self): + + # null by default + handler = self.make_handler() + self.assertIsNone(handler.dbkey) + + # but caller can specify + handler = self.make_handler(dbkey="another") + self.assertEqual(handler.dbkey, "another") + + def test_make_importer_factory(self): + model = self.app.model + handler = self.make_handler() + + # returns a typical importer + factory = handler.make_importer_factory(model.User, "User") + self.assertTrue(issubclass(factory, mod.FromWuttaMirror)) + self.assertTrue(issubclass(factory, ToWutta)) + self.assertIs(factory.model_class, model.User) + self.assertEqual(factory.__name__, "UserImporter") + + def test_define_importers(self): + handler = self.make_handler() + + # all models are included + importers = handler.define_importers() + self.assertIn("Setting", importers) + self.assertIn("Person", importers) + self.assertIn("Role", importers) + self.assertIn("Permission", importers) + self.assertIn("User", importers) + self.assertIn("UserRole", importers) + self.assertIn("UserAPIToken", importers) + self.assertIn("Upgrade", importers) + self.assertNotIn("BatchMixin", importers) + self.assertNotIn("BatchRowMixin", importers) + self.assertNotIn("Base", importers) + + # also, dependencies are implied by sort order + models = list(importers) + self.assertLess(models.index("Person"), models.index("User")) + self.assertLess(models.index("User"), models.index("UserRole")) + self.assertLess(models.index("User"), models.index("Upgrade")) + + +class TestFromWuttaToWuttaImport(DataTestCase): + + def make_handler(self, **kwargs): + return mod.FromWuttaToWuttaImport(self.config, **kwargs) + + def test_make_source_session(self): + + # error if null dbkey + handler = self.make_handler() + self.assertIsNone(handler.dbkey) + self.assertRaises(ValueError, handler.make_source_session) + + # error if dbkey not found + handler = self.make_handler(dbkey="another") + self.assertEqual(handler.dbkey, "another") + self.assertNotIn("another", self.config.appdb_engines) + self.assertRaises(ValueError, handler.make_source_session) + + # error if dbkey is 'default' + handler = self.make_handler(dbkey="default") + self.assertEqual(handler.dbkey, "default") + self.assertIn("default", self.config.appdb_engines) + self.assertRaises(ValueError, handler.make_source_session) + + # expected behavior + another_engine = sa.create_engine("sqlite://") + handler = self.make_handler(dbkey="another") + with patch.dict(self.config.appdb_engines, {"another": another_engine}): + session = handler.make_source_session() + self.assertIs(session.bind, another_engine) + + +class TestFromWuttaToWuttaExport(DataTestCase): + + def make_handler(self, **kwargs): + return mod.FromWuttaToWuttaExport(self.config, **kwargs) + + def test_make_target_session(self): + + # error if null dbkey + handler = self.make_handler() + self.assertIsNone(handler.dbkey) + self.assertRaises(ValueError, handler.make_target_session) + + # error if dbkey not found + handler = self.make_handler(dbkey="another") + self.assertEqual(handler.dbkey, "another") + self.assertNotIn("another", self.config.appdb_engines) + self.assertRaises(ValueError, handler.make_target_session) + + # error if dbkey is 'default' + handler = self.make_handler(dbkey="default") + self.assertEqual(handler.dbkey, "default") + self.assertIn("default", self.config.appdb_engines) + self.assertRaises(ValueError, handler.make_target_session) + + # expected behavior + another_engine = sa.create_engine("sqlite://") + handler = self.make_handler(dbkey="another") + with patch.dict(self.config.appdb_engines, {"another": another_engine}): + session = handler.make_target_session() + self.assertIs(session.bind, another_engine) diff --git a/tests/test_app.py b/tests/test_app.py index 560d89d..23eb4bd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,6 +46,18 @@ class TestWuttaSyncAppProvider(ConfigTestCase): self.assertIn(FromCsvToWutta, handlers) self.assertIn(FromFooToBar, handlers) + # now for something completely different..here we pretend there + # are multiple handler entry points with same key. all should + # be returned, including both which share the key. + entry_points = { + "import.to_baz.from_foo": [FromFooToBaz1, FromFooToBaz2], + } + with patch.object(mod, "load_entry_points", return_value=entry_points): + handlers = self.app.get_all_import_handlers() + self.assertEqual(len(handlers), 2) + self.assertIn(FromFooToBaz1, handlers) + self.assertIn(FromFooToBaz2, handlers) + def test_get_designated_import_handler_spec(self): # fetch of unknown key returns none @@ -139,6 +151,14 @@ class TestWuttaSyncAppProvider(ConfigTestCase): handler = self.app.get_import_handler("import.to_wutta.from_csv") self.assertIsInstance(handler, FromCsvToWutta) self.assertIsInstance(handler, FromCsvToPoser) + self.assertFalse(hasattr(handler, "foo_attr")) + + # can pass extra kwargs + handler = self.app.get_import_handler( + "import.to_wutta.from_csv", foo_attr="whatever" + ) + self.assertTrue(hasattr(handler, "foo_attr")) + self.assertEqual(handler.foo_attr, "whatever") # unknown importer cannot be found handler = self.app.get_import_handler("bogus") diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..eefa5b7 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.testing import ConfigTestCase + +from wuttasync import conf as mod + + +class TestWuttaSyncConfig(ConfigTestCase): + + def make_extension(self): + return mod.WuttaSyncConfig() + + def test_default_import_handlers(self): + + # base config has no default handlers + spec = self.config.get( + "wuttasync.importing.import.to_wutta.from_wutta.default_handler" + ) + self.assertIsNone(spec) + spec = self.config.get( + "wuttasync.importing.export.to_wutta.from_wutta.default_handler" + ) + self.assertIsNone(spec) + + # extend config + ext = self.make_extension() + ext.configure(self.config) + + # config now has default handlers + spec = self.config.get( + "wuttasync.importing.import.to_wutta.from_wutta.default_handler" + ) + self.assertIsNotNone(spec) + self.assertEqual(spec, "wuttasync.importing.wutta:FromWuttaToWuttaImport") + spec = self.config.get( + "wuttasync.importing.export.to_wutta.from_wutta.default_handler" + ) + self.assertIsNotNone(spec) + self.assertEqual(spec, "wuttasync.importing.wutta:FromWuttaToWuttaExport") diff --git a/tests/test_emails.py b/tests/test_emails.py index 9494753..fc927bf 100644 --- a/tests/test_emails.py +++ b/tests/test_emails.py @@ -5,6 +5,7 @@ from wuttjamaican.testing import ConfigTestCase from wuttasync import emails as mod from wuttasync.importing import ImportHandler from wuttasync.testing import ImportExportWarningTestCase +from wuttasync.conf import WuttaSyncConfig class FromFooToWutta(ImportHandler): @@ -74,8 +75,21 @@ class TestImportExportWarning(ConfigTestCase): class TestEmailSettings(ImportExportWarningTestCase): + def make_config(self, files=None, **kwargs): + config = super().make_config(files, **kwargs) + + # need this to ensure default import/export handlers. since + # behavior can vary depending on what packages are installed. + ext = WuttaSyncConfig() + ext.configure(config) + + return config + def test_import_to_versions_from_wutta_warning(self): self.do_test_preview("import_to_versions_from_wutta_warning") def test_import_to_wutta_from_csv_warning(self): self.do_test_preview("import_to_wutta_from_csv_warning") + + def test_import_to_wutta_from_wutta_warning(self): + self.do_test_preview("import_to_wutta_from_wutta_warning")