fix: add --list-models option for import/export commands

also rename the command decorators for consistency
This commit is contained in:
Lance Edgar 2024-12-06 09:06:45 -06:00
parent 7ee551d446
commit 15b2cb07ba
8 changed files with 230 additions and 47 deletions

31
docs/glossary.rst Normal file
View file

@ -0,0 +1,31 @@
.. _glossary:
Glossary
========
.. glossary::
:sorted:
import handler
This a type of :term:`handler` which is responsible for a
particular set of data import/export task(s).
The import handler manages data connections and transactions, and
invokes one or more :term:`importers <importer>` to process the
data.
Note that "import/export handler" is the more proper term to use
here but it is often shortened to just "import handler" for
convenience.
importer
In the context of WuttaSync, this refers to a type of object
which can process data for an import/export job, i.e. create,
update or delete records on the "target" based on the "source"
data it reads.
See also :term:`import handler` which can "contain" one or more
importers.
Note that "importer/exporter" is the more proper term to use here
but it is often shortened to just "importer" for convenience.

View file

@ -22,6 +22,7 @@ database`, it may be used for any "source → target" data flow.
:maxdepth: 2
:caption: Documentation
glossary
narr/install
narr/cli

View file

@ -25,11 +25,12 @@ WuttaSync - ``wutta`` subcommands
This namespace exposes the following:
* :func:`~wuttasync.cli.base.importer_command()`
* :func:`~wuttasync.cli.base.file_importer_command()`
* :func:`~wuttasync.cli.base.import_command()`
* :func:`~wuttasync.cli.base.file_import_command()`
* :class:`~wuttasync.cli.base.ImportCommandHandler`
"""
from .base import importer_command, file_importer_command
from .base import import_command, file_import_command, ImportCommandHandler
# nb. must bring in all modules for discovery to work
from . import import_csv

View file

@ -25,6 +25,8 @@
"""
import inspect
import logging
import sys
from pathlib import Path
from typing import List, Optional
from typing_extensions import Annotated
@ -32,14 +34,118 @@ from typing_extensions import Annotated
import makefun
import typer
from wuttjamaican.app import GenericHandler
from wuttasync.importing import ImportHandler
def importer_command_template(
log = logging.getLogger(__name__)
class ImportCommandHandler(GenericHandler):
"""
This is the :term:`handler` responsible for import/export command
line runs.
Normally, the command (actually :term:`subcommand`) logic will
create this handler and call its :meth:`run()` method.
This handler does not know how to import/export data, but it knows
how to make its :attr:`import_handler` do it.
:param import_handler: During construction, caller can specify the
:attr:`import_handler` as any of:
* import handler instance
* import handler factory (e.g. class)
* import handler spec (cf. :func:`~wuttjamaican:wuttjamaican.util.load_object()`)
For example::
handler = ImportCommandHandler(
config, import_handler='wuttasync.importing.csv:FromCsvToWutta')
"""
import_handler = None
"""
Reference to the :term:`import handler` instance, which is to be
invoked when command runs. See also :meth:`run()`.
"""
def __init__(self, config, import_handler=None):
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)
else: # spec
factory = self.app.load_object(import_handler)
self.import_handler = factory(self.config)
def run(self, params, progress=None):
"""
Run the import/export job(s) based on command line params.
This mostly just calls
:meth:`~wuttasync.importing.handlers.ImportHandler.process_data()`
for the :attr:`import_handler`.
Unless ``--list-models`` was specified on the command line in
which case we do :meth:`list_models()` instead.
:param params: Dict of params from command line. This must
include a ``'models'`` key, the rest are optional.
:param progress: Optional progress indicator factory.
"""
# maybe just list models and bail
if params.get('list_models'):
self.list_models(params)
return
# otherwise process some data
kw = dict(params)
models = kw.pop('models')
log.debug("using handler: %s", self.import_handler.get_spec())
# TODO: need to use all/default models if none specified
# (and should know models by now for logging purposes)
log.debug("running %s %s for: %s",
self.import_handler,
self.import_handler.orientation.value,
', '.join(models))
log.debug("params are: %s", kw)
self.import_handler.process_data(*models, **kw)
def list_models(self, params):
"""
Query the :attr:`import_handler`'s supported target models and
print the info to stdout.
This is what happens when command line has ``--list-models``.
"""
sys.stdout.write("ALL MODELS:\n")
sys.stdout.write("==============================\n")
for key in self.import_handler.importers:
sys.stdout.write(key)
sys.stdout.write("\n")
sys.stdout.write("==============================\n")
def import_command_template(
# model keys
models: Annotated[
Optional[List[str]],
typer.Argument(help="Model(s) to process. Can specify one or more, "
"or omit to process all default models.")] = None,
"or omit to process default models.")] = None,
# list models
list_models: Annotated[
bool,
typer.Option('--list-models', '-l',
help="List available target models and exit.")] = False,
# allow create?
create: Annotated[
@ -75,22 +181,22 @@ def importer_command_template(
):
"""
Stub function which provides a common param signature; used with
:func:`importer_command()`.
:func:`import_command()`.
"""
def importer_command(fn):
def import_command(fn):
"""
Decorator for import/export commands. Adds common params based on
:func:`importer_command_template()`.
:func:`import_command_template()`.
To use this, e.g. for ``poser import-foo`` command::
from poser.cli import poser_typer
from wuttasync.cli import importer_command
from wuttasync.cli import import_command, ImportCommandHandler
@poser_typer.command()
@importer_command
@import_command
def import_foo(
ctx: typer.Context,
**kwargs
@ -98,16 +204,15 @@ def importer_command(fn):
\"""
Import data from Foo API to Poser DB
\"""
from poser.importing.foo import FromFooToPoser
config = ctx.parent.wutta_config
kw = dict(ctx.params)
models = kw.pop('models')
handler = FromFooToPoser(config)
handler.process_data(*models, **kw)
handler = ImportCommandHandler(
config, import_handler='poser.importing.foo:FromFooToPoser')
handler.run(ctx.params)
See also :class:`ImportCommandHandler`.
"""
original_sig = inspect.signature(fn)
reference_sig = inspect.signature(importer_command_template)
reference_sig = inspect.signature(import_command_template)
params = list(original_sig.parameters.values())
for i, param in enumerate(reference_sig.parameters.values()):
@ -120,7 +225,7 @@ def importer_command(fn):
return makefun.create_function(final_sig, fn)
def file_importer_command_template(
def file_import_command_template(
input_file_path: Annotated[
Path,
typer.Option('--input-path',
@ -132,23 +237,23 @@ def file_importer_command_template(
"""
Stub function to provide signature for import/export commands
which require input file. Used with
:func:`file_importer_command()`.
:func:`file_import_command()`.
"""
def file_importer_command(fn):
def file_import_command(fn):
"""
Decorator for import/export commands which require input file.
Adds common params based on
:func:`file_importer_command_template()`.
:func:`file_import_command_template()`.
To use this, it's the same method as shown for
:func:`importer_command()` except in this case you would use the
``file_importer_command`` decorator.
:func:`import_command()` except in this case you would use the
``file_import_command`` decorator.
"""
original_sig = inspect.signature(fn)
plain_import_sig = inspect.signature(importer_command_template)
file_import_sig = inspect.signature(file_importer_command_template)
plain_import_sig = inspect.signature(import_command_template)
file_import_sig = inspect.signature(file_import_command_template)
desired_params = (
list(plain_import_sig.parameters.values())
+ list(file_import_sig.parameters.values()))

View file

@ -30,11 +30,11 @@ import typer
from wuttjamaican.cli import wutta_typer
from .base import file_importer_command
from .base import file_import_command, ImportCommandHandler
@wutta_typer.command()
@file_importer_command
@file_import_command
def import_csv(
ctx: typer.Context,
**kwargs
@ -42,10 +42,7 @@ def import_csv(
"""
Import data from CSV file(s) to Wutta DB
"""
from wuttasync.importing.csv import FromCsvToWutta
config = ctx.parent.wutta_config
kw = dict(ctx.params)
models = kw.pop('models')
handler = FromCsvToWutta(config)
handler.process_data(*models, **kw)
handler = ImportCommandHandler(
config, import_handler='wuttasync.importing.csv:FromCsvToWutta')
handler.run(ctx.params)

View file

@ -373,6 +373,8 @@ class Importer:
updated = []
deleted = []
log.debug("using key fields: %s", ', '.join(self.get_keys()))
# get complete set of normalized source data
if source_data is None:
source_data = self.normalize_source_data(progress=progress)

View file

@ -2,8 +2,59 @@
import inspect
from unittest import TestCase
from unittest.mock import patch
from wuttasync.cli import base as mod
from wuttjamaican.testing import DataTestCase
class TestImportCommandHandler(DataTestCase):
def make_handler(self, **kwargs):
return mod.ImportCommandHandler(self.config, **kwargs)
def test_import_handler(self):
# none
handler = self.make_handler()
self.assertIsNone(handler.import_handler)
FromCsvToWutta = self.app.load_object('wuttasync.importing.csv:FromCsvToWutta')
# as spec
handler = self.make_handler(import_handler=FromCsvToWutta.get_spec())
self.assertIsInstance(handler.import_handler, FromCsvToWutta)
# as factory
handler = self.make_handler(import_handler=FromCsvToWutta)
self.assertIsInstance(handler.import_handler, FromCsvToWutta)
# as instance
myhandler = FromCsvToWutta(self.config)
handler = self.make_handler(import_handler=myhandler)
self.assertIs(handler.import_handler, myhandler)
def test_run(self):
handler = self.make_handler(import_handler='wuttasync.importing.csv:FromCsvToWutta')
with patch.object(handler, 'list_models') as list_models:
handler.run({'list_models': True})
list_models.assert_called_once_with({'list_models': True})
with patch.object(handler, 'import_handler') as import_handler:
handler.run({'models': []})
import_handler.process_data.assert_called_once_with()
def test_list_models(self):
handler = self.make_handler(import_handler='wuttasync.importing.csv:FromCsvToWutta')
with patch.object(mod, 'sys') as sys:
handler.list_models({})
# just test a few random things we expect to see
self.assertTrue(sys.stdout.write.has_call('ALL MODELS:\n'))
self.assertTrue(sys.stdout.write.has_call('Person'))
self.assertTrue(sys.stdout.write.has_call('User'))
self.assertTrue(sys.stdout.write.has_call('Upgrade'))
class TestImporterCommand(TestCase):
@ -15,7 +66,7 @@ class TestImporterCommand(TestCase):
sig1 = inspect.signature(myfunc)
self.assertIn('kwargs', sig1.parameters)
self.assertNotIn('dry_run', sig1.parameters)
wrapt = mod.importer_command(myfunc)
wrapt = mod.import_command(myfunc)
sig2 = inspect.signature(wrapt)
self.assertNotIn('kwargs', sig2.parameters)
self.assertIn('dry_run', sig2.parameters)
@ -31,7 +82,7 @@ class TestFileImporterCommand(TestCase):
self.assertIn('kwargs', sig1.parameters)
self.assertNotIn('dry_run', sig1.parameters)
self.assertNotIn('input_file_path', sig1.parameters)
wrapt = mod.file_importer_command(myfunc)
wrapt = mod.file_import_command(myfunc)
sig2 = inspect.signature(wrapt)
self.assertNotIn('kwargs', sig2.parameters)
self.assertIn('dry_run', sig2.parameters)

View file

@ -1,24 +1,19 @@
#-*- coding: utf-8; -*-
import os
from unittest import TestCase
from unittest.mock import MagicMock, patch
from wuttasync.cli import import_csv as mod
from wuttasync.importing.csv import FromCsvToWutta
from wuttasync.cli import import_csv as mod, ImportCommandHandler
here = os.path.dirname(__file__)
example_conf = os.path.join(here, 'example.conf')
class TestImportCsv(TestCase):
def test_basic(self):
ctx = MagicMock(params={'models': [],
'create': True, 'update': True, 'delete': False,
'dry_run': True})
with patch.object(FromCsvToWutta, 'process_data') as process_data:
params = {'models': [],
'create': True, 'update': True, 'delete': False,
'dry_run': True}
ctx = MagicMock(params=params)
with patch.object(ImportCommandHandler, 'run') as run:
mod.import_csv(ctx)
process_data.assert_called_once_with(create=True, update=True, delete=False,
dry_run=True)
run.assert_called_once_with(params)