diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53852c4..6ce2ae2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to WuttaSync will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.5.0 (2026-01-03)
+
+### Feat
+
+- add support for `wutta export-csv` command
+
+### Fix
+
+- add `actioner` property for ImportHandler
+
## v0.4.0 (2025-12-31)
### Feat
diff --git a/docs/api/wuttasync.cli.export_csv.rst b/docs/api/wuttasync.cli.export_csv.rst
new file mode 100644
index 0000000..c0d9779
--- /dev/null
+++ b/docs/api/wuttasync.cli.export_csv.rst
@@ -0,0 +1,6 @@
+
+``wuttasync.cli.export_csv``
+============================
+
+.. automodule:: wuttasync.cli.export_csv
+ :members:
diff --git a/docs/api/wuttasync.exporting.base.rst b/docs/api/wuttasync.exporting.base.rst
new file mode 100644
index 0000000..32aeac5
--- /dev/null
+++ b/docs/api/wuttasync.exporting.base.rst
@@ -0,0 +1,6 @@
+
+``wuttasync.exporting.base``
+============================
+
+.. automodule:: wuttasync.exporting.base
+ :members:
diff --git a/docs/api/wuttasync.exporting.csv.rst b/docs/api/wuttasync.exporting.csv.rst
new file mode 100644
index 0000000..66a0c24
--- /dev/null
+++ b/docs/api/wuttasync.exporting.csv.rst
@@ -0,0 +1,6 @@
+
+``wuttasync.exporting.csv``
+===========================
+
+.. automodule:: wuttasync.exporting.csv
+ :members:
diff --git a/docs/api/wuttasync.exporting.handlers.rst b/docs/api/wuttasync.exporting.handlers.rst
new file mode 100644
index 0000000..bacde60
--- /dev/null
+++ b/docs/api/wuttasync.exporting.handlers.rst
@@ -0,0 +1,6 @@
+
+``wuttasync.exporting.handlers``
+================================
+
+.. automodule:: wuttasync.exporting.handlers
+ :members:
diff --git a/docs/api/wuttasync.exporting.rst b/docs/api/wuttasync.exporting.rst
new file mode 100644
index 0000000..9215689
--- /dev/null
+++ b/docs/api/wuttasync.exporting.rst
@@ -0,0 +1,6 @@
+
+``wuttasync.exporting``
+=======================
+
+.. automodule:: wuttasync.exporting
+ :members:
diff --git a/docs/index.rst b/docs/index.rst
index e6fea22..215e892 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -73,9 +73,14 @@ cf. :doc:`rattail-manual:data/sync/index`.
api/wuttasync.app
api/wuttasync.cli
api/wuttasync.cli.base
+ api/wuttasync.cli.export_csv
api/wuttasync.cli.import_csv
api/wuttasync.cli.import_versions
api/wuttasync.emails
+ api/wuttasync.exporting
+ api/wuttasync.exporting.base
+ api/wuttasync.exporting.csv
+ api/wuttasync.exporting.handlers
api/wuttasync.importing
api/wuttasync.importing.base
api/wuttasync.importing.csv
diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst
index ac6fb14..5cb3123 100644
--- a/docs/narr/cli/builtin.rst
+++ b/docs/narr/cli/builtin.rst
@@ -9,6 +9,24 @@ WuttaSync.
It is fairly simple to add more; see :doc:`custom`.
+.. _wutta-export-csv:
+
+``wutta export-csv``
+--------------------
+
+Export data from the Wutta :term:`app database` to CSV file(s).
+
+This *should* be able to automatically export any table mapped in the
+:term:`app model`. The only caveat is that it is "dumb" and does not
+have any special field handling. This means the column headers in the
+CSV will be the same as in the source table, and some data types may
+not behave as expected etc.
+
+Defined in: :mod:`wuttasync.cli.export_csv`
+
+.. program-output:: wutta export-csv --help
+
+
.. _wutta-import-csv:
``wutta import-csv``
diff --git a/pyproject.toml b/pyproject.toml
index 2718664..b61d057 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaSync"
-version = "0.4.0"
+version = "0.5.0"
description = "Wutta Framework for data import/export and real-time sync"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@@ -28,6 +28,7 @@ requires-python = ">= 3.8"
dependencies = [
"humanize",
"makefun",
+ "rich",
"SQLAlchemy-Utils",
"WuttJamaican[db]>=0.28.1",
]
@@ -42,6 +43,7 @@ tests = ["pylint", "pytest", "pytest-cov", "tox", "Wutta-Continuum>=0.3.0"]
wuttasync = "wuttasync.app:WuttaSyncAppProvider"
[project.entry-points."wuttasync.importing"]
+"export.to_csv.from_wutta" = "wuttasync.exporting.csv:FromWuttaToCsv"
"import.to_versions.from_wutta" = "wuttasync.importing.versions:FromWuttaToVersions"
"import.to_wutta.from_csv" = "wuttasync.importing.csv:FromCsvToWutta"
diff --git a/src/wuttasync/cli/__init__.py b/src/wuttasync/cli/__init__.py
index 0d88ed4..a3fa82b 100644
--- a/src/wuttasync/cli/__init__.py
+++ b/src/wuttasync/cli/__init__.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.
#
@@ -26,12 +26,19 @@ WuttaSync - ``wutta`` subcommands
This namespace exposes the following:
* :func:`~wuttasync.cli.base.import_command()`
+* :func:`~wuttasync.cli.base.file_export_command()`
* :func:`~wuttasync.cli.base.file_import_command()`
* :class:`~wuttasync.cli.base.ImportCommandHandler`
"""
-from .base import import_command, file_import_command, ImportCommandHandler
+from .base import (
+ import_command,
+ file_export_command,
+ file_import_command,
+ ImportCommandHandler,
+)
# nb. must bring in all modules for discovery to work
+from . import export_csv
from . import import_csv
from . import import_versions
diff --git a/src/wuttasync/cli/base.py b/src/wuttasync/cli/base.py
index 34be0e7..68bb536 100644
--- a/src/wuttasync/cli/base.py
+++ b/src/wuttasync/cli/base.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.
#
@@ -32,10 +32,12 @@ from typing import List, Optional
from typing_extensions import Annotated
import makefun
+import rich
import typer
from wuttjamaican.app import GenericHandler
-from wuttasync.importing import ImportHandler
+from wuttasync.importing import ImportHandler, FromFileHandler
+from wuttasync.exporting import ToFileHandler
log = logging.getLogger(__name__)
@@ -123,9 +125,30 @@ class ImportCommandHandler(GenericHandler):
self.list_models(ctx.params)
return
- # otherwise we'll process some data
+ # otherwise we'll (hopefully) process some data
log.debug("using handler: %s", self.import_handler.get_spec())
+ # but first, some extra checks for certain file-based
+ # handlers. this must be done here, because these CLI params
+ # are not technically required (otherwise typer would handle
+ # this instead of us here). and that is because we want to
+ # allow user to specify --list without needing to also specify
+ # --input or --output
+ if isinstance(self.import_handler, FromFileHandler):
+ if not ctx.params.get("input_file_path"):
+ rich.print(
+ "\n[bold yellow]must specify --input folder/file path[/bold yellow]\n",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ elif isinstance(self.import_handler, ToFileHandler):
+ if not ctx.params.get("output_file_path"):
+ rich.print(
+ "\n[bold yellow]must specify --output folder/file path[/bold yellow]\n",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
# all params from caller will be passed along
kw = dict(ctx.params)
@@ -136,7 +159,7 @@ class ImportCommandHandler(GenericHandler):
kw["transaction_comment"] = comment
# sort out which models to process
- models = kw.pop("models")
+ models = kw.pop("models", None)
if not models:
models = list(self.import_handler.importers)
log.debug(
@@ -170,7 +193,7 @@ def import_command_template( # pylint: disable=unused-argument,too-many-argumen
models: Annotated[
Optional[List[str]],
typer.Argument(
- help="Model(s) to process. Can specify one or more, "
+ help="Target model(s) to process. Specify one or more, "
"or omit to process default models."
),
] = None,
@@ -324,17 +347,65 @@ def import_command(fn):
return makefun.create_function(final_sig, fn)
-def file_import_command_template( # pylint: disable=unused-argument
- input_file_path: Annotated[
+def file_export_command_template( # pylint: disable=unused-argument
+ # nb. technically this is required, but not if doing --list
+ # (so we cannot mark it required here, for that reason)
+ output_file_path: Annotated[
Path,
typer.Option(
- "--input-path",
+ "--output",
+ "-o",
exists=True,
file_okay=True,
dir_okay=True,
- help="Path to input file(s). Can be a folder "
- "if app logic can guess the filename(s); "
- "otherwise must be complete file path.",
+ help="Path to output folder. Or full path to output file "
+ "if only running one target model.",
+ ),
+ ] = None,
+):
+ """
+ Stub function to provide signature for exporter commands which
+ produce data file(s) as output. Used with
+ :func:`file_export_command`.
+ """
+
+
+def file_export_command(fn):
+ """
+ Decorator for file export commands. Adds common params based on
+ :func:`file_export_command_template`.
+ """
+ original_sig = inspect.signature(fn)
+ plain_import_sig = inspect.signature(import_command_template)
+ file_export_sig = inspect.signature(file_export_command_template)
+ desired_params = list(plain_import_sig.parameters.values()) + list(
+ file_export_sig.parameters.values()
+ )
+
+ params = list(original_sig.parameters.values())
+ for i, param in enumerate(desired_params):
+ params.insert(i + 1, param)
+
+ # remove the **kwargs param
+ params.pop(-1)
+
+ final_sig = original_sig.replace(parameters=params)
+ return makefun.create_function(final_sig, fn)
+
+
+def file_import_command_template( # pylint: disable=unused-argument
+ # nb. technically this is required, but not if doing --list
+ # (so we cannot mark it required here, for that reason)
+ input_file_path: Annotated[
+ Path,
+ typer.Option(
+ "--input",
+ "-i",
+ exists=True,
+ file_okay=True,
+ dir_okay=True,
+ help="Path to input folder. Or full path to input file "
+ "if only running one target model.",
),
] = None,
):
diff --git a/src/wuttasync/cli/export_csv.py b/src/wuttasync/cli/export_csv.py
new file mode 100644
index 0000000..b100211
--- /dev/null
+++ b/src/wuttasync/cli/export_csv.py
@@ -0,0 +1,42 @@
+# -*- 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-csv`
+"""
+
+import typer
+
+from wuttjamaican.cli import wutta_typer
+
+from .base import file_export_command, ImportCommandHandler
+
+
+@wutta_typer.command()
+@file_export_command
+def export_csv(ctx: typer.Context, **kwargs): # pylint: disable=unused-argument
+ """
+ Export data from Wutta DB to CSV file(s)
+ """
+ config = ctx.parent.wutta_config
+ handler = ImportCommandHandler(config, key="export.to_csv.from_wutta")
+ handler.run(ctx)
diff --git a/src/wuttasync/exporting/__init__.py b/src/wuttasync/exporting/__init__.py
new file mode 100644
index 0000000..4003c86
--- /dev/null
+++ b/src/wuttasync/exporting/__init__.py
@@ -0,0 +1,43 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Data Import / Export Framework
+
+This namespace exposes the following:
+
+* :enum:`~wuttasync.importing.handlers.Orientation`
+
+And some :term:`export handler ` base classes:
+
+* :class:`~wuttasync.exporting.handlers.ExportHandler`
+* :class:`~wuttasync.exporting.handlers.ToFileHandler`
+
+And some :term:`exporter ` base classes:
+
+* :class:`~wuttasync.exporting.base.ToFile`
+
+See also the :mod:`wuttasync.importing` module.
+"""
+
+from .handlers import Orientation, ExportHandler, ToFileHandler
+from .base import ToFile
diff --git a/src/wuttasync/exporting/base.py b/src/wuttasync/exporting/base.py
new file mode 100644
index 0000000..2d8d011
--- /dev/null
+++ b/src/wuttasync/exporting/base.py
@@ -0,0 +1,166 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Data exporter base classes
+"""
+
+import os
+
+from wuttasync.importing import Importer
+
+
+class ToFile(Importer):
+ """
+ Base class for importer/exporter using output file as data target.
+
+ Depending on the subclass, it may be able to "guess" (at least
+ partially) the path to the output file. If not, and/or to avoid
+ ambiguity, the caller must specify the file path.
+
+ In most cases caller may specify any of these via kwarg to the
+ class constructor, or e.g.
+ :meth:`~wuttasync.importing.handlers.ImportHandler.process_data()`:
+
+ * :attr:`output_file_path`
+ * :attr:`output_file_name`
+
+ The subclass itself can also specify via override of these
+ methods:
+
+ * :meth:`get_output_file_path()`
+ * :meth:`get_output_file_name()`
+
+ And of course subclass must override these too:
+
+ * :meth:`open_output_file()`
+ * :meth:`close_output_file()`
+ * (and see also :attr:`output_file`)
+ """
+
+ output_file_path = None
+ """
+ Path to output folder, or file.
+
+ The ideal usage is to set this to the output *folder* path. That
+ allows the handler to run several importers in one go. The same
+ output folder path is given to each importer; they then each
+ determine their own output filename within that.
+
+ But you can also set this to the full output folder + file path,
+ e.g. if you're just running one importer. This would override
+ the importer's own logic for determining output filename.
+
+ See also :meth:`get_output_file_path()` and
+ :meth:`get_output_file_name()`.
+ """
+
+ output_file_name = None
+ """
+ Optional static output file name (sans folder path).
+
+ If set, this will be used as output filename instead of the
+ importer determining one on its own.
+
+ See also :meth:`get_output_file_name()`.
+ """
+
+ output_file = None
+ """
+ Handle to the open output file, if applicable. May be set by
+ :meth:`open_output_file()` for later reference within
+ :meth:`close_output_file()`.
+ """
+
+ def setup(self):
+ """
+ Open the output file. See also :meth:`open_output_file()`.
+ """
+ if not self.dry_run:
+ self.open_output_file()
+
+ def teardown(self):
+ """
+ Close the output file. See also :meth:`close_output_file()`.
+ """
+ if not self.dry_run:
+ self.close_output_file()
+
+ def get_output_file_path(self):
+ """
+ This must return the full path to output file.
+
+ Default logic inspects :attr:`output_file_path`; if that
+ points to a folder then it is combined with
+ :meth:`get_output_file_name()`. Otherwise it's returned
+ as-is.
+
+ :returns: Path to output file, as string
+ """
+ path = self.output_file_path
+ if not path:
+ raise ValueError("must set output_file_path")
+
+ if os.path.isdir(path):
+ filename = self.get_output_file_name()
+ return os.path.join(path, filename)
+
+ return path
+
+ def get_output_file_name(self):
+ """
+ This must return the output filename, sans folder path.
+
+ Default logic will return :attr:`output_file_name` if set,
+ otherwise raise error.
+
+ :returns: Output filename, sans folder path
+ """
+ if self.output_file_name:
+ return self.output_file_name
+
+ raise NotImplementedError("can't guess output filename")
+
+ def open_output_file(self):
+ """
+ Open the output file for writing target data.
+
+ Subclass must override to specify how this happens; default
+ logic is not implemented. Remember to set :attr:`output_file`
+ if applicable for reference when closing.
+
+ See also :attr:`get_output_file_path()` and
+ :meth:`close_output_file()`.
+ """
+ raise NotImplementedError
+
+ def close_output_file(self):
+ """
+ Close the output file for target data.
+
+ Subclass must override to specify how this happens; default
+ logic blindly calls the ``close()`` method on whatever
+ :attr:`output_file` happens to point to.
+
+ See also :attr:`open_output_file()`.
+ """
+ self.output_file.close()
diff --git a/src/wuttasync/exporting/csv.py b/src/wuttasync/exporting/csv.py
new file mode 100644
index 0000000..4d2597e
--- /dev/null
+++ b/src/wuttasync/exporting/csv.py
@@ -0,0 +1,321 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Exporting to CSV
+"""
+
+import csv
+import logging
+from collections import OrderedDict
+
+import sqlalchemy as sa
+from sqlalchemy_utils.functions import get_primary_keys, get_columns
+
+from wuttjamaican.db.util import make_topo_sortkey
+
+from wuttasync.importing import FromWuttaHandler, FromWutta
+from wuttasync.exporting import ToFileHandler, ToFile
+
+
+log = logging.getLogger(__name__)
+
+
+class ToCsv(ToFile): # pylint: disable=abstract-method
+ """
+ Base class for exporter using CSV file as data target.
+
+ This inherits from :class:`~wuttasync.exporting.base.ToFile`.
+ """
+
+ output_writer = None
+ """
+ While the output file is open, this will reference a
+ :class:`python:csv.DictWriter` instance.
+ """
+
+ csv_encoding = "utf_8"
+ """
+ Encoding used for the CSV output file.
+
+ You can specify an override if needed when calling
+ :meth:`~wuttasync.importing.handlers.ImportHandler.process_data()`.
+ """
+
+ def get_output_file_name(self): # pylint: disable=empty-docstring
+ """ """
+ if self.output_file_name:
+ return self.output_file_name
+
+ model_title = self.get_model_title()
+ return f"{model_title}.csv"
+
+ def open_output_file(self):
+ """
+ Opens the output CSV file for writing.
+
+ This calls
+ :meth:`~wuttasync.exporting.base.ToFile.get_output_file_path()`
+ and opens that file. It sets
+ :attr:`~wuttasync.exporting.base.ToFile.output_file` and also
+ :attr:`output_writer`. And it calls
+ :meth:`write_output_header()` to write the field header row.
+ """
+ path = self.get_output_file_path()
+ log.debug("opening output file: %s", path)
+
+ self.output_file = open( # pylint: disable=consider-using-with
+ path, "wt", encoding=self.csv_encoding
+ )
+
+ self.output_writer = csv.DictWriter(
+ self.output_file,
+ self.fields,
+ # quoting=csv.QUOTE_NONNUMERIC
+ )
+
+ self.write_output_header()
+
+ def write_output_header(self):
+ """
+ Write the field header row to the CSV file.
+
+ Default logic calls
+ :meth:`~python:csv.DictWriter.writeheader()` on the
+ :attr:`output_writer` instance.
+ """
+ self.output_writer.writeheader()
+
+ def close_output_file(self): # pylint: disable=empty-docstring
+ """ """
+ self.output_writer = None
+ self.output_file.close()
+ self.output_file = None
+
+ def update_target_object(self, obj, source_data, target_data=None):
+ """
+ In a CSV export the assumption is we always start with an
+ empty file, so "create" is the only logical action for each
+ record - there are no updates or deletes per se.
+
+ But under the hood, this method is used for create as well, so
+ we override it and actually write the record to CSV file.
+ Unless :attr:`~wuttasync.importing.base.Importer.dry_run` is
+ true, this calls :meth:`~python:csv.csvwriter.writerow()` on
+ the :attr:`output_writer` instance.
+
+ See also parent method docs,
+ :meth:`~wuttasync.importing.base.Importer.update_target_object()`
+ """
+ data = self.coerce_csv(source_data)
+ if not self.dry_run:
+ self.output_writer.writerow(data)
+ return data
+
+ def coerce_csv(self, data): # pylint: disable=missing-function-docstring
+ coerced = {}
+ for field in self.fields:
+ value = data[field]
+
+ if value is None:
+ value = ""
+
+ elif isinstance(value, (int, float)):
+ pass
+
+ else:
+ value = str(value)
+
+ coerced[field] = value
+ return coerced
+
+
+class FromSqlalchemyToCsvMixin:
+ """
+ Mixin class for SQLAlchemy ORM → CSV :term:`exporters `.
+
+ Such exporters are generated automatically by
+ :class:`FromSqlalchemyToCsvHandlerMixin`, so you won't typically
+ reference this mixin class directly.
+
+ This mixin effectively behaves like the
+ :attr:`~wuttasync.importing.base.Importer.model_class` represents
+ the source side instead of the target. It uses
+ :attr:`~wuttasync.importing.base.FromSqlalchemy.source_model_class`
+ instead, for automatic things like inspecting the fields list.
+ """
+
+ def get_model_title(self): # pylint: disable=missing-function-docstring
+ if hasattr(self, "model_title"):
+ return self.model_title
+ return self.source_model_class.__name__
+
+ def get_simple_fields(self): # pylint: disable=missing-function-docstring
+ if hasattr(self, "simple_fields"):
+ return self.simple_fields
+ try:
+ fields = get_columns(self.source_model_class)
+ except sa.exc.NoInspectionAvailable:
+ return []
+ return list(fields.keys())
+
+ def normalize_source_object(
+ self, obj
+ ): # pylint: disable=missing-function-docstring
+ fields = self.get_fields()
+ fields = [f for f in self.get_simple_fields() if f in fields]
+ data = {field: getattr(obj, field) for field in fields}
+ return data
+
+ def make_object(self): # pylint: disable=missing-function-docstring
+ return self.source_model_class()
+
+
+class FromSqlalchemyToCsvHandlerMixin:
+ """
+ Mixin class for SQLAlchemy ORM → CSV :term:`export handlers
+ `.
+
+ This knows how to dynamically generate :term:`exporter `
+ classes to represent the models in the source ORM. Such classes
+ will inherit from :class:`FromSqlalchemyToCsvMixin`, in addition
+ to whatever :attr:`FromImporterBase` and :attr:`ToImporterBase`
+ reference.
+
+ That all happens within :meth:`define_importers()`.
+ """
+
+ target_key = "csv"
+ generic_target_title = "CSV"
+
+ # nb. subclass must define this
+ FromImporterBase = None
+ """
+ For a handler to use this mixin, it must set this to a valid base
+ class for the ORM source side. The :meth:`define_importers()`
+ logic will use this when dynamically generating new exporter
+ classes.
+ """
+
+ ToImporterBase = ToCsv
+ """
+ This must be set to a valid base class for the CSV target side.
+ Default is :class:`ToCsv` which should typically be fine; you can
+ change if needed.
+ """
+
+ def get_source_model(self):
+ """
+ This should return the :term:`app model` or a similar module
+ containing data model classes for the source side.
+
+ The source model is used to dynamically generate a set of
+ exporters (e.g. one per table in the source DB) which can use
+ CSV file as data target. See also :meth:`define_importers()`.
+
+ Subclass must override this if needed; default behavior is not
+ implemented.
+ """
+ raise NotImplementedError
+
+ def define_importers(self):
+ """
+ This mixin overrides typical (manual) importer definition, and
+ instead dynamically generates a set of exporters, e.g. one per
+ table in the source DB.
+
+ It does this based on the source model, as returned by
+ :meth:`get_source_model()`. It calls
+ :meth:`make_importer_factory()` for each model class found.
+ """
+ importers = {}
+ model = self.get_source_model()
+
+ # 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 a new :term:`exporter ` class, targeting
+ the given :term:`data model` class.
+
+ The newly-created class will inherit from:
+
+ * :class:`FromSqlalchemyToCsvMixin`
+ * :attr:`FromImporterBase`
+ * :attr:`ToImporterBase`
+
+ :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",
+ (FromSqlalchemyToCsvMixin, self.FromImporterBase, self.ToImporterBase),
+ {
+ "source_model_class": model_class,
+ "default_keys": list(get_primary_keys(model_class)),
+ },
+ )
+
+
+class ToCsvHandler(ToFileHandler):
+ """
+ Base class for export handlers using CSV file(s) as data target.
+ """
+
+
+class FromWuttaToCsv(
+ FromSqlalchemyToCsvHandlerMixin, FromWuttaHandler, ToCsvHandler
+): # pylint: disable=too-many-ancestors
+ """
+ Handler for Wutta (:term:`app database`) → CSV export.
+
+ This uses :class:`FromSqlalchemyToCsvHandlerMixin` for most of the
+ heavy lifting.
+ """
+
+ FromImporterBase = FromWutta
+
+ def get_source_model(self): # pylint: disable=empty-docstring
+ """ """
+ return self.app.model
diff --git a/src/wuttasync/exporting/handlers.py b/src/wuttasync/exporting/handlers.py
new file mode 100644
index 0000000..91b460c
--- /dev/null
+++ b/src/wuttasync/exporting/handlers.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 .
+#
+################################################################################
+"""
+Export Handlers
+"""
+
+from wuttasync.importing import ImportHandler, Orientation
+
+
+class ExportHandler(ImportHandler):
+ """
+ Generic base class for :term:`export handlers `.
+
+ This is really just
+ :class:`~wuttasync.importing.handlers.ImportHandler` with the
+ orientation flipped.
+ """
+
+ orientation = Orientation.EXPORT
+ "" # nb. suppress docs
+
+
+class ToFileHandler(ExportHandler):
+ """
+ Base class for export handlers which use output file(s) as the
+ data target.
+
+ Importers (exporters) used by this handler are generally assumed
+ to subclass :class:`~wuttasync.exporting.base.ToFile`.
+ """
diff --git a/src/wuttasync/importing/__init__.py b/src/wuttasync/importing/__init__.py
index 545cbb9..d71c870 100644
--- a/src/wuttasync/importing/__init__.py
+++ b/src/wuttasync/importing/__init__.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.
#
@@ -31,6 +31,8 @@ And some :term:`import handler` base classes:
* :class:`~wuttasync.importing.handlers.ImportHandler`
* :class:`~wuttasync.importing.handlers.FromFileHandler`
+* :class:`~wuttasync.importing.handlers.FromSqlalchemyHandler`
+* :class:`~wuttasync.importing.handlers.FromWuttaHandler`
* :class:`~wuttasync.importing.handlers.ToSqlalchemyHandler`
* :class:`~wuttasync.importing.handlers.ToWuttaHandler`
@@ -38,16 +40,22 @@ And some :term:`importer` base classes:
* :class:`~wuttasync.importing.base.Importer`
* :class:`~wuttasync.importing.base.FromFile`
+* :class:`~wuttasync.importing.base.FromSqlalchemy`
+* :class:`~wuttasync.importing.base.FromWutta`
* :class:`~wuttasync.importing.base.ToSqlalchemy`
* :class:`~wuttasync.importing.model.ToWutta`
+
+See also the :mod:`wuttasync.exporting` module.
"""
from .handlers import (
Orientation,
ImportHandler,
FromFileHandler,
+ FromSqlalchemyHandler,
+ FromWuttaHandler,
ToSqlalchemyHandler,
ToWuttaHandler,
)
-from .base import Importer, FromFile, ToSqlalchemy
+from .base import Importer, FromFile, FromSqlalchemy, FromWutta, ToSqlalchemy
from .model import ToWutta
diff --git a/src/wuttasync/importing/base.py b/src/wuttasync/importing/base.py
index b625b17..dd65190 100644
--- a/src/wuttasync/importing/base.py
+++ b/src/wuttasync/importing/base.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.
#
@@ -1448,6 +1448,13 @@ class FromSqlalchemyMirror(FromSqlalchemy): # pylint: disable=abstract-method
return self.normalize_target_object(obj)
+class FromWutta(FromSqlalchemy): # pylint: disable=abstract-method
+ """
+ Base class for data importer/exporter which uses the Wutta ORM
+ (:term:`app database`) as data source.
+ """
+
+
class ToSqlalchemy(Importer):
"""
Base class for importer/exporter which uses SQLAlchemy ORM on the
diff --git a/src/wuttasync/importing/csv.py b/src/wuttasync/importing/csv.py
index 9190099..60c51eb 100644
--- a/src/wuttasync/importing/csv.py
+++ b/src/wuttasync/importing/csv.py
@@ -391,6 +391,10 @@ def make_coercer(attr): # pylint: disable=too-many-return-statements
):
return coerce_datetime
+ # Date
+ if isinstance(attr.type, sa.Date):
+ return coerce_date
+
# Float
# nb. check this before decimal, since Numeric inherits from Float
if isinstance(attr.type, sa.Float):
@@ -423,6 +427,13 @@ def coerce_boolean_nullable(value): # pylint: disable=missing-function-docstrin
return coerce_boolean(value)
+def coerce_date(value): # pylint: disable=missing-function-docstring
+ if value == "":
+ return None
+
+ return datetime.datetime.strptime(value, "%Y-%m-%d").date()
+
+
def coerce_datetime(value): # pylint: disable=missing-function-docstring
if value == "":
return None
diff --git a/src/wuttasync/importing/handlers.py b/src/wuttasync/importing/handlers.py
index d3c3ab5..cc53bdf 100644
--- a/src/wuttasync/importing/handlers.py
+++ b/src/wuttasync/importing/handlers.py
@@ -210,6 +210,19 @@ class ImportHandler( # pylint: disable=too-many-public-methods,too-many-instanc
""" """
return self.get_title()
+ @property
+ def actioner(self):
+ """
+ Convenience property which effectively returns the
+ :attr:`orientation` as a noun - i.e. one of:
+
+ * ``'importer'``
+ * ``'exporter'``
+
+ See also :attr:`actioning`.
+ """
+ return f"{self.orientation.value}er"
+
@property
def actioning(self):
"""
@@ -218,6 +231,8 @@ class ImportHandler( # pylint: disable=too-many-public-methods,too-many-instanc
* ``'importing'``
* ``'exporting'``
+
+ See also :attr:`actioner`.
"""
return f"{self.orientation.value}ing"
diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py
index 2370bdd..209dbca 100644
--- a/tests/cli/test_base.py
+++ b/tests/cli/test_base.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8; -*-
import inspect
+import sys
from unittest import TestCase
from unittest.mock import patch, Mock
@@ -67,6 +68,75 @@ class TestImportCommandHandler(DataTestCase):
transaction_comment="hello world",
)
+ def test_run_missing_input(self):
+ handler = self.make_handler(
+ import_handler="wuttasync.importing.csv:FromCsvToWutta"
+ )
+
+ class Object:
+ def __init__(self, **kw):
+ self.__dict__.update(kw)
+
+ # fails without input_file_path
+ with patch.object(sys, "exit") as exit_:
+ exit_.side_effect = RuntimeError
+ ctx = Object(
+ params={},
+ parent=Object(params={}),
+ )
+ try:
+ handler.run(ctx)
+ except RuntimeError:
+ pass
+ exit_.assert_called_once_with(1)
+
+ # runs with input_file_path
+ with patch.object(sys, "exit") as exit_:
+ exit_.side_effect = RuntimeError
+ ctx = Object(
+ params={"input_file_path": self.tempdir},
+ parent=Object(
+ params={},
+ ),
+ )
+ self.assertRaises(FileNotFoundError, handler.run, ctx)
+ exit_.assert_not_called()
+
+ def test_run_missing_output(self):
+ handler = self.make_handler(
+ import_handler="wuttasync.exporting.csv:FromWuttaToCsv"
+ )
+
+ class Object:
+ def __init__(self, **kw):
+ self.__dict__.update(kw)
+
+ # fails without output_file_path
+ with patch.object(sys, "exit") as exit_:
+ exit_.side_effect = RuntimeError
+ ctx = Object(
+ params={},
+ parent=Object(params={}),
+ )
+ try:
+ handler.run(ctx)
+ except RuntimeError:
+ pass
+ exit_.assert_called_once_with(1)
+
+ # runs with output_file_path
+ with patch.object(sys, "exit") as exit_:
+ exit_.side_effect = RuntimeError
+ ctx = Object(
+ params={"output_file_path": self.tempdir},
+ parent=Object(
+ params={},
+ ),
+ )
+ # self.assertRaises(FileNotFoundError, handler.run, ctx)
+ handler.run(ctx)
+ exit_.assert_not_called()
+
def test_list_models(self):
handler = self.make_handler(
import_handler="wuttasync.importing.csv:FromCsvToWutta"
@@ -96,6 +166,23 @@ class TestImporterCommand(TestCase):
self.assertIn("dry_run", sig2.parameters)
+class TestFileExporterCommand(TestCase):
+
+ def test_basic(self):
+ def myfunc(ctx, **kwargs):
+ pass
+
+ sig1 = inspect.signature(myfunc)
+ self.assertIn("kwargs", sig1.parameters)
+ self.assertNotIn("dry_run", sig1.parameters)
+ self.assertNotIn("output_file_path", sig1.parameters)
+ wrapt = mod.file_export_command(myfunc)
+ sig2 = inspect.signature(wrapt)
+ self.assertNotIn("kwargs", sig2.parameters)
+ self.assertIn("dry_run", sig2.parameters)
+ self.assertIn("output_file_path", sig2.parameters)
+
+
class TestFileImporterCommand(TestCase):
def test_basic(self):
diff --git a/tests/cli/test_export_csv.py b/tests/cli/test_export_csv.py
new file mode 100644
index 0000000..07a6e4f
--- /dev/null
+++ b/tests/cli/test_export_csv.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock, patch
+
+from wuttasync.cli import export_csv as mod, ImportCommandHandler
+
+
+class TestExportCsv(TestCase):
+
+ def test_basic(self):
+ params = {
+ "models": [],
+ "create": True,
+ "update": True,
+ "delete": False,
+ "dry_run": True,
+ }
+ ctx = MagicMock(params=params)
+ with patch.object(ImportCommandHandler, "run") as run:
+ mod.export_csv(ctx)
+ run.assert_called_once_with(ctx)
diff --git a/tests/exporting/test_base.py b/tests/exporting/test_base.py
new file mode 100644
index 0000000..fd3ae20
--- /dev/null
+++ b/tests/exporting/test_base.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from wuttjamaican.testing import DataTestCase
+
+from wuttasync.exporting import base as mod, ExportHandler
+
+
+class TestToFile(DataTestCase):
+
+ def setUp(self):
+ self.setup_db()
+ self.handler = ExportHandler(self.config)
+
+ def make_exporter(self, **kwargs):
+ kwargs.setdefault("handler", self.handler)
+ return mod.ToFile(self.config, **kwargs)
+
+ def test_setup(self):
+ model = self.app.model
+
+ # output file is opened
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertFalse(exp.dry_run)
+ with patch.object(exp, "open_output_file") as open_output_file:
+ exp.setup()
+ open_output_file.assert_called_once_with()
+
+ # but not if in dry run mode
+ with patch.object(self.handler, "dry_run", new=True):
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertTrue(exp.dry_run)
+ with patch.object(exp, "open_output_file") as open_output_file:
+ exp.setup()
+ open_output_file.assert_not_called()
+
+ def test_teardown(self):
+ model = self.app.model
+
+ # output file is closed
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertFalse(exp.dry_run)
+ with patch.object(exp, "close_output_file") as close_output_file:
+ exp.teardown()
+ close_output_file.assert_called_once_with()
+
+ # but not if in dry run mode
+ with patch.object(self.handler, "dry_run", new=True):
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertTrue(exp.dry_run)
+ with patch.object(exp, "close_output_file") as close_output_file:
+ exp.teardown()
+ close_output_file.assert_not_called()
+
+ def test_get_output_file_path(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ # output path must be set
+ self.assertRaises(ValueError, exp.get_output_file_path)
+
+ # path is guessed from dir+filename
+ path1 = self.write_file("data1.txt", "")
+ exp.output_file_path = self.tempdir
+ exp.output_file_name = "data1.txt"
+ self.assertEqual(exp.get_output_file_path(), path1)
+
+ # path can be explicitly set
+ path2 = self.write_file("data2.txt", "")
+ exp.output_file_path = path2
+ self.assertEqual(exp.get_output_file_path(), path2)
+
+ def test_get_output_file_name(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ # name cannot be guessed
+ self.assertRaises(NotImplementedError, exp.get_output_file_name)
+
+ # name can be explicitly set
+ exp.output_file_name = "data.txt"
+ self.assertEqual(exp.get_output_file_name(), "data.txt")
+
+ def test_open_output_file(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertRaises(NotImplementedError, exp.open_output_file)
+
+ def test_close_output_file(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ path = self.write_file("data.txt", "")
+ with open(path, "wt") as f:
+ exp.output_file = f
+ with patch.object(f, "close") as close:
+ exp.close_output_file()
+ close.assert_called_once_with()
diff --git a/tests/exporting/test_csv.py b/tests/exporting/test_csv.py
new file mode 100644
index 0000000..aa7f455
--- /dev/null
+++ b/tests/exporting/test_csv.py
@@ -0,0 +1,209 @@
+# -*- coding: utf-8; -*-
+
+import csv
+import io
+from unittest.mock import patch
+
+from wuttjamaican.testing import DataTestCase
+
+from wuttasync.exporting import csv as mod, ExportHandler
+from wuttasync.importing import FromWuttaHandler, FromWutta
+
+
+class TestToCsv(DataTestCase):
+
+ def setUp(self):
+ self.setup_db()
+ self.handler = ExportHandler(self.config)
+
+ def make_exporter(self, **kwargs):
+ kwargs.setdefault("handler", self.handler)
+ kwargs.setdefault("output_file_path", self.tempdir)
+ return mod.ToCsv(self.config, **kwargs)
+
+ def test_get_output_file_name(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ # name can be guessed
+ self.assertEqual(exp.get_output_file_name(), "Setting.csv")
+
+ # name can be explicitly set
+ exp.output_file_name = "data.txt"
+ self.assertEqual(exp.get_output_file_name(), "data.txt")
+
+ def test_open_output_file(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+ self.assertIsNone(exp.output_file)
+ self.assertIsNone(exp.output_writer)
+ exp.open_output_file()
+ try:
+ self.assertIsInstance(exp.output_file, io.TextIOBase)
+ self.assertIsInstance(exp.output_writer, csv.DictWriter)
+ finally:
+ exp.output_file.close()
+
+ def test_close_output_file(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ self.assertIsNone(exp.output_file)
+ self.assertIsNone(exp.output_writer)
+ exp.open_output_file()
+ self.assertIsNotNone(exp.output_file)
+ self.assertIsNotNone(exp.output_writer)
+ exp.close_output_file()
+ self.assertIsNone(exp.output_file)
+ self.assertIsNone(exp.output_writer)
+
+ def test_coerce_csv(self):
+ model = self.app.model
+
+ # string value
+ exp = self.make_exporter(model_class=model.Setting)
+ result = exp.coerce_csv({"name": "foo", "value": "bar"})
+ self.assertEqual(result, {"name": "foo", "value": "bar"})
+
+ # null value converts to empty string
+ result = exp.coerce_csv({"name": "foo", "value": None})
+ self.assertEqual(result, {"name": "foo", "value": ""})
+
+ # float value passed thru as-is
+ result = exp.coerce_csv({"name": "foo", "value": 12.34})
+ self.assertEqual(result, {"name": "foo", "value": 12.34})
+ self.assertIsInstance(result["value"], float)
+
+ def test_update_target_object(self):
+ model = self.app.model
+ exp = self.make_exporter(model_class=model.Setting)
+
+ exp.setup()
+
+ with patch.object(exp, "output_writer") as output_writer:
+
+ # writer is called for normal run
+ data = {"name": "foo", "value": "bar"}
+ exp.update_target_object(None, data)
+ output_writer.writerow.assert_called_once_with(data)
+
+ # but not called for dry run
+ output_writer.writerow.reset_mock()
+ with patch.object(self.handler, "dry_run", new=True):
+ exp.update_target_object(None, data)
+ output_writer.writerow.assert_not_called()
+
+ exp.teardown()
+
+
+class MockMixinExporter(mod.FromSqlalchemyToCsvMixin, FromWutta, mod.ToCsv):
+ pass
+
+
+class TestFromSqlalchemyToCsvMixin(DataTestCase):
+
+ def setUp(self):
+ self.setup_db()
+ self.handler = ExportHandler(self.config)
+
+ def make_exporter(self, **kwargs):
+ kwargs.setdefault("handler", self.handler)
+ return MockMixinExporter(self.config, **kwargs)
+
+ def test_model_title(self):
+ model = self.app.model
+ exp = self.make_exporter(source_model_class=model.Setting)
+
+ # default comes from model class
+ self.assertEqual(exp.get_model_title(), "Setting")
+
+ # but can override
+ exp.model_title = "Widget"
+ self.assertEqual(exp.get_model_title(), "Widget")
+
+ def test_get_simple_fields(self):
+ model = self.app.model
+ exp = self.make_exporter(source_model_class=model.Setting)
+
+ # default comes from model class
+ self.assertEqual(exp.get_simple_fields(), ["name", "value"])
+
+ # but can override
+ exp.simple_fields = ["name"]
+ self.assertEqual(exp.get_simple_fields(), ["name"])
+
+ # no default if no model class
+ exp = self.make_exporter()
+ self.assertEqual(exp.get_simple_fields(), [])
+
+ def test_normalize_source_object(self):
+ model = self.app.model
+ exp = self.make_exporter(source_model_class=model.Setting)
+ setting = model.Setting(name="foo", value="bar")
+ data = exp.normalize_source_object(setting)
+ self.assertEqual(data, {"name": "foo", "value": "bar"})
+
+ def test_make_object(self):
+ model = self.app.model
+
+ # normal
+ exp = self.make_exporter(source_model_class=model.Setting)
+ obj = exp.make_object()
+ self.assertIsInstance(obj, model.Setting)
+
+ # no model_class
+ exp = self.make_exporter()
+ self.assertRaises(TypeError, exp.make_object)
+
+
+class MockMixinHandler(
+ mod.FromSqlalchemyToCsvHandlerMixin, FromWuttaHandler, mod.ToCsvHandler
+):
+ FromImporterBase = FromWutta
+
+
+class TestFromSqlalchemyToCsvHandlerMixin(DataTestCase):
+
+ def make_handler(self, **kwargs):
+ return MockMixinHandler(self.config, **kwargs)
+
+ def test_get_source_model(self):
+ with patch.object(
+ mod.FromSqlalchemyToCsvHandlerMixin, "define_importers", return_value={}
+ ):
+ handler = self.make_handler()
+ self.assertRaises(NotImplementedError, handler.get_source_model)
+
+ def test_define_importers(self):
+ model = self.app.model
+ with patch.object(
+ mod.FromSqlalchemyToCsvHandlerMixin, "get_source_model", return_value=model
+ ):
+ handler = self.make_handler()
+ importers = handler.define_importers()
+ self.assertIn("Setting", importers)
+ self.assertTrue(issubclass(importers["Setting"], FromWutta))
+ self.assertTrue(issubclass(importers["Setting"], mod.ToCsv))
+ self.assertIn("User", importers)
+ self.assertIn("Person", importers)
+ self.assertIn("Role", importers)
+
+ def test_make_importer_factory(self):
+ model = self.app.model
+ with patch.object(
+ mod.FromSqlalchemyToCsvHandlerMixin, "define_importers", return_value={}
+ ):
+ handler = self.make_handler()
+ factory = handler.make_importer_factory(model.Setting, "Setting")
+ self.assertTrue(issubclass(factory, FromWutta))
+ self.assertTrue(issubclass(factory, mod.ToCsv))
+
+
+class TestFromWuttaToCsv(DataTestCase):
+
+ def make_handler(self, **kwargs):
+ return mod.FromWuttaToCsv(self.config, **kwargs)
+
+ def test_get_source_model(self):
+ handler = self.make_handler()
+ self.assertIs(handler.get_source_model(), self.app.model)
diff --git a/tests/exporting/test_handlers.py b/tests/exporting/test_handlers.py
new file mode 100644
index 0000000..cc9751f
--- /dev/null
+++ b/tests/exporting/test_handlers.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8; -*-
+
+# nothing to test yet really, just ensuring coverage
+from wuttasync.exporting import handlers as mod
diff --git a/tests/importing/test_csv.py b/tests/importing/test_csv.py
index b3f0fad..b36bee9 100644
--- a/tests/importing/test_csv.py
+++ b/tests/importing/test_csv.py
@@ -271,6 +271,9 @@ class Example(Base):
flag = sa.Column(sa.Boolean(), nullable=False)
optional_flag = sa.Column(sa.Boolean(), nullable=True)
+ date = sa.Column(sa.Date(), nullable=False)
+ optional_date = sa.Column(sa.Date(), nullable=True)
+
dt = sa.Column(sa.DateTime(), nullable=False)
optional_dt = sa.Column(sa.DateTime(), nullable=True)
@@ -285,7 +288,7 @@ class TestMakeCoercers(TestCase):
def test_basic(self):
coercers = mod.make_coercers(Example)
- self.assertEqual(len(coercers), 12)
+ self.assertEqual(len(coercers), 14)
self.assertIs(coercers["id"], mod.coerce_integer)
self.assertIs(coercers["optional_id"], mod.coerce_integer)
@@ -293,6 +296,8 @@ class TestMakeCoercers(TestCase):
self.assertIs(coercers["optional_name"], mod.coerce_string_nullable)
self.assertIs(coercers["flag"], mod.coerce_boolean)
self.assertIs(coercers["optional_flag"], mod.coerce_boolean_nullable)
+ self.assertIs(coercers["date"], mod.coerce_date)
+ self.assertIs(coercers["optional_date"], mod.coerce_date)
self.assertIs(coercers["dt"], mod.coerce_datetime)
self.assertIs(coercers["optional_dt"], mod.coerce_datetime)
self.assertIs(coercers["dec"], mod.coerce_decimal)
@@ -322,6 +327,12 @@ class TestMakeCoercer(TestCase):
func = mod.make_coercer(Example.optional_flag)
self.assertIs(func, mod.coerce_boolean_nullable)
+ func = mod.make_coercer(Example.date)
+ self.assertIs(func, mod.coerce_date)
+
+ func = mod.make_coercer(Example.optional_date)
+ self.assertIs(func, mod.coerce_date)
+
func = mod.make_coercer(Example.dt)
self.assertIs(func, mod.coerce_datetime)
@@ -365,6 +376,15 @@ class TestCoercers(TestCase):
self.assertIsNone(mod.coerce_boolean_nullable(""))
+ def test_coerce_date(self):
+ self.assertIsNone(mod.coerce_date(""))
+
+ value = mod.coerce_date("2025-10-19")
+ self.assertIsInstance(value, datetime.date)
+ self.assertEqual(value, datetime.date(2025, 10, 19))
+
+ self.assertRaises(ValueError, mod.coerce_date, "XXX")
+
def test_coerce_datetime(self):
self.assertIsNone(mod.coerce_datetime(""))
diff --git a/tests/importing/test_handlers.py b/tests/importing/test_handlers.py
index 21fcaeb..659cda1 100644
--- a/tests/importing/test_handlers.py
+++ b/tests/importing/test_handlers.py
@@ -27,6 +27,13 @@ class TestImportHandler(DataTestCase):
handler.target_title = "Wutta"
self.assertEqual(str(handler), "CSV → Wutta")
+ def test_actioner(self):
+ handler = self.make_handler()
+ self.assertEqual(handler.actioner, "importer")
+
+ handler.orientation = mod.Orientation.EXPORT
+ self.assertEqual(handler.actioner, "exporter")
+
def test_actioning(self):
handler = self.make_handler()
self.assertEqual(handler.actioning, "importing")