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")