feat: add support for Wutta <-> Wutta import/export

This commit is contained in:
Lance Edgar 2026-03-17 17:15:34 -05:00
parent bfc45bd0f0
commit ae282ab468
25 changed files with 719 additions and 21 deletions

View file

@ -0,0 +1,6 @@
``wuttasync.cli.export_wutta``
==============================
.. automodule:: wuttasync.cli.export_wutta
:members:

View file

@ -0,0 +1,6 @@
``wuttasync.cli.import_wutta``
==============================
.. automodule:: wuttasync.cli.import_wutta
:members:

View file

@ -0,0 +1,6 @@
``wuttasync.conf``
==================
.. automodule:: wuttasync.conf
:members:

View file

@ -74,8 +74,11 @@ cf. :doc:`rattail-manual:data/sync/index`.
api/wuttasync.cli api/wuttasync.cli
api/wuttasync.cli.base api/wuttasync.cli.base
api/wuttasync.cli.export_csv api/wuttasync.cli.export_csv
api/wuttasync.cli.export_wutta
api/wuttasync.cli.import_csv api/wuttasync.cli.import_csv
api/wuttasync.cli.import_versions api/wuttasync.cli.import_versions
api/wuttasync.cli.import_wutta
api/wuttasync.conf
api/wuttasync.emails api/wuttasync.emails
api/wuttasync.exporting api/wuttasync.exporting
api/wuttasync.exporting.base api/wuttasync.exporting.base

View file

@ -27,6 +27,18 @@ Defined in: :mod:`wuttasync.cli.export_csv`
.. program-output:: wutta export-csv --help .. 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:
``wutta import-csv`` ``wutta import-csv``
@ -64,3 +76,15 @@ in the :term:`app model`.
Defined in: :mod:`wuttasync.cli.import_versions` Defined in: :mod:`wuttasync.cli.import_versions`
.. program-output:: wutta import-versions --help .. 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

View file

@ -42,10 +42,15 @@ tests = ["pylint", "pytest", "pytest-cov", "tox", "Wutta-Continuum>=0.3.0"]
[project.entry-points."wutta.app.providers"] [project.entry-points."wutta.app.providers"]
wuttasync = "wuttasync.app:WuttaSyncAppProvider" wuttasync = "wuttasync.app:WuttaSyncAppProvider"
[project.entry-points."wutta.config.extensions"]
"wuttasync" = "wuttasync.conf:WuttaSyncConfig"
[project.entry-points."wuttasync.importing"] [project.entry-points."wuttasync.importing"]
"export.to_csv.from_wutta" = "wuttasync.exporting.csv:FromWuttaToCsv" "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_versions.from_wutta" = "wuttasync.importing.versions:FromWuttaToVersions"
"import.to_wutta.from_csv" = "wuttasync.importing.csv:FromCsvToWutta" "import.to_wutta.from_csv" = "wuttasync.importing.csv:FromCsvToWutta"
"import.to_wutta.from_wutta" = "wuttasync.importing.wutta:FromWuttaToWuttaImport"
[project.entry-points."wutta.typer_imports"] [project.entry-points."wutta.typer_imports"]
wuttasync = "wuttasync.cli" wuttasync = "wuttasync.cli"

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttaSync -- Wutta Framework for data import/export and real-time sync # 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. # This file is part of Wutta Framework.
# #
@ -87,18 +87,32 @@ class WuttaSyncAppProvider(AppProvider):
:returns: List of all import/export handler classes :returns: List of all import/export handler classes
""" """
# first load all "registered" Handler classes # first load all "registered" Handler classes. note we must
factories = load_entry_points("wuttasync.importing", ignore_errors=True) # 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 # 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 # many handlers may not be registered per se, but may be
# designated via config. so try to include those too # designated via config. so try to include those too
for factory in factories.values(): seen = set()
spec = self.get_designated_import_handler_spec(factory.get_key()) 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: if spec and spec not in specs:
specs[spec] = self.app.load_object(spec) specs[spec] = self.app.load_object(spec)
seen.add(key)
# flatten back to simple list of classes # flatten back to simple list of classes
factories = list(specs.values()) factories = list(specs.values())
@ -203,22 +217,26 @@ class WuttaSyncAppProvider(AppProvider):
:param require: Set this to true if you want an error raised :param require: Set this to true if you want an error raised
when no handler is found. 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 :returns: The import/export handler instance. If no handler
is found, then ``None`` is returned, unless ``require`` is found, then ``None`` is returned, unless ``require``
param is true, in which case error is raised. param is true, in which case error is raised.
""" """
# first try to fetch the handler per designated spec # 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: if spec:
factory = self.app.load_object(spec) factory = self.app.load_object(spec)
return factory(self.config) return factory(self.config, **kwargs)
# nothing was designated, so leverage logic which already # nothing was designated, so leverage logic which already
# sorts out which handler is "designated" for given key # sorts out which handler is "designated" for given key
designated = self.get_designated_import_handlers() designated = self.get_designated_import_handlers()
for handler in designated: for handler in designated:
if handler.get_key() == key: if handler.get_key() == key:
return handler factory = type(handler)
return factory(self.config, **kwargs)
if require: if require:
raise ValueError(f"Cannot locate import handler for key: {key}") raise ValueError(f"Cannot locate import handler for key: {key}")

View file

@ -40,5 +40,7 @@ from .base import (
# nb. must bring in all modules for discovery to work # nb. must bring in all modules for discovery to work
from . import export_csv from . import export_csv
from . import export_wutta
from . import import_csv from . import import_csv
from . import import_versions from . import import_versions
from . import import_wutta

View file

@ -65,7 +65,13 @@ class ImportCommandHandler(GenericHandler):
:param key: Optional :term:`import/export key` to use for handler :param key: Optional :term:`import/export key` to use for handler
lookup. Only used if ``import_handler`` param is not set. 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( handler = ImportCommandHandler(
config, "poser.importing.foo:FromFooToPoser" config, "poser.importing.foo:FromFooToPoser"
@ -81,6 +87,14 @@ class ImportCommandHandler(GenericHandler):
See also See also
:meth:`~wuttasync.app.WuttaSyncAppProvider.get_import_handler()` :meth:`~wuttasync.app.WuttaSyncAppProvider.get_import_handler()`
which does the lookup by key. 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 import_handler = None
@ -89,20 +103,22 @@ class ImportCommandHandler(GenericHandler):
invoked when command runs. See also :meth:`run()`. 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) super().__init__(config)
if import_handler: if import_handler:
if isinstance(import_handler, ImportHandler): if isinstance(import_handler, ImportHandler):
self.import_handler = import_handler self.import_handler = import_handler
elif callable(import_handler): elif callable(import_handler):
self.import_handler = import_handler(self.config) self.import_handler = import_handler(self.config, **kwargs)
else: # spec else: # spec
factory = self.app.load_object(import_handler) factory = self.app.load_object(import_handler)
self.import_handler = factory(self.config) self.import_handler = factory(self.config, **kwargs)
elif key: elif key:
self.import_handler = self.app.get_import_handler(key, require=True) self.import_handler = self.app.get_import_handler(
key, require=True, **kwargs
)
def run(self, ctx, progress=None): # pylint: disable=unused-argument def run(self, ctx, progress=None): # pylint: disable=unused-argument
""" """

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

50
src/wuttasync/conf.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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",
)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttaSync -- Wutta Framework for data import/export and real-time sync # 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. # 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. Diff warning for CSV Wutta import.
""" """
class import_to_wutta_from_wutta_warning( # pylint: disable=invalid-name
ImportExportWarning
):
"""
Diff warning for Wutta Wutta import.
"""

View file

@ -239,7 +239,7 @@ class FromCsvToSqlalchemyHandlerMixin:
raise NotImplementedError raise NotImplementedError
# TODO: pylint (correctly) flags this as duplicate code, matching # 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): def define_importers(self):
""" """
This mixin overrides typical (manual) importer definition, and This mixin overrides typical (manual) importer definition, and

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttaSync -- Wutta Framework for data import/export and real-time sync # 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. # 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): 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() self.importers = self.define_importers()
def __str__(self): def __str__(self):

View file

@ -138,7 +138,7 @@ class FromWuttaToVersions(FromWuttaHandler, ToWuttaHandler):
return kwargs return kwargs
# TODO: pylint (correctly) flags this as duplicate code, matching # 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): def define_importers(self):
""" """
This overrides typical (manual) importer definition, instead This overrides typical (manual) importer definition, instead

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttaSync -- Wutta Framework for data import/export and real-time sync # 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. # This file is part of Wutta Framework.
# #
@ -24,10 +24,156 @@
Wutta Wutta import/export 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 .base import FromSqlalchemyMirror
from .model import ToWutta
from .handlers import FromWuttaHandler, ToWuttaHandler, Orientation
class FromWuttaMirror(FromSqlalchemyMirror): # pylint: disable=abstract-method class FromWuttaMirror(FromSqlalchemyMirror): # pylint: disable=abstract-method
""" """
Base class for Wutta -> Wutta data importers. Base class for Wutta Wutta data :term:`importers/exporters
<importer>`.
This inherits from
:class:`~wuttasync.importing.base.FromSqlalchemyMirror`.
""" """
class FromWuttaToWuttaBase(FromWuttaHandler, ToWuttaHandler):
"""
Base class for Wutta Wutta data :term:`import/export handlers
<import handler>`.
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)

View file

@ -25,19 +25,55 @@ class TestImportCommandHandler(DataTestCase):
# as spec # as spec
handler = self.make_handler(import_handler=FromCsvToWutta.get_spec()) handler = self.make_handler(import_handler=FromCsvToWutta.get_spec())
self.assertIsInstance(handler.import_handler, FromCsvToWutta) 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 # as factory
handler = self.make_handler(import_handler=FromCsvToWutta) handler = self.make_handler(import_handler=FromCsvToWutta)
self.assertIsInstance(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 # as instance
myhandler = FromCsvToWutta(self.config) myhandler = FromCsvToWutta(self.config)
handler = self.make_handler(import_handler=myhandler) handler = self.make_handler(import_handler=myhandler)
self.assertIs(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 # as key
handler = self.make_handler(key="import.to_wutta.from_csv") handler = self.make_handler(key="import.to_wutta.from_csv")
self.assertIsInstance(handler.import_handler, FromCsvToWutta) 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): def test_run(self):
handler = self.make_handler( handler = self.make_handler(

View file

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

View file

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

View file

@ -19,6 +19,17 @@ class TestImportHandler(DataTestCase):
def make_handler(self, **kwargs): def make_handler(self, **kwargs):
return mod.ImportHandler(self.config, **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): def test_str(self):
handler = self.make_handler() handler = self.make_handler()
self.assertEqual(str(handler), "None → None") self.assertEqual(str(handler), "None → None")

View file

@ -1,3 +1,134 @@
# -*- coding: utf-8; -*- # -*- 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 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)

View file

@ -46,6 +46,18 @@ class TestWuttaSyncAppProvider(ConfigTestCase):
self.assertIn(FromCsvToWutta, handlers) self.assertIn(FromCsvToWutta, handlers)
self.assertIn(FromFooToBar, 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): def test_get_designated_import_handler_spec(self):
# fetch of unknown key returns none # fetch of unknown key returns none
@ -139,6 +151,14 @@ class TestWuttaSyncAppProvider(ConfigTestCase):
handler = self.app.get_import_handler("import.to_wutta.from_csv") handler = self.app.get_import_handler("import.to_wutta.from_csv")
self.assertIsInstance(handler, FromCsvToWutta) self.assertIsInstance(handler, FromCsvToWutta)
self.assertIsInstance(handler, FromCsvToPoser) 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 # unknown importer cannot be found
handler = self.app.get_import_handler("bogus") handler = self.app.get_import_handler("bogus")

39
tests/test_conf.py Normal file
View file

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

View file

@ -5,6 +5,7 @@ from wuttjamaican.testing import ConfigTestCase
from wuttasync import emails as mod from wuttasync import emails as mod
from wuttasync.importing import ImportHandler from wuttasync.importing import ImportHandler
from wuttasync.testing import ImportExportWarningTestCase from wuttasync.testing import ImportExportWarningTestCase
from wuttasync.conf import WuttaSyncConfig
class FromFooToWutta(ImportHandler): class FromFooToWutta(ImportHandler):
@ -74,8 +75,21 @@ class TestImportExportWarning(ConfigTestCase):
class TestEmailSettings(ImportExportWarningTestCase): 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): def test_import_to_versions_from_wutta_warning(self):
self.do_test_preview("import_to_versions_from_wutta_warning") self.do_test_preview("import_to_versions_from_wutta_warning")
def test_import_to_wutta_from_csv_warning(self): def test_import_to_wutta_from_csv_warning(self):
self.do_test_preview("import_to_wutta_from_csv_warning") 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")