diff --git a/CHANGELOG.md b/CHANGELOG.md index acf72ba..ac3d97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,6 @@ All notable changes to WuttJamaican 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.20.0 (2025-01-11) - -### Feat - -- add basic support for "reports" feature - -### Fix - -- add `render_percent()` method for app handler -- set global default sender to root@localhost - ## v0.19.3 (2025-01-09) ### Fix diff --git a/docs/api/wuttjamaican.reports.rst b/docs/api/wuttjamaican.reports.rst deleted file mode 100644 index 0d79149..0000000 --- a/docs/api/wuttjamaican.reports.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.reports`` -======================== - -.. automodule:: wuttjamaican.reports - :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index 22f5ad5..3a16ea2 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -247,33 +247,6 @@ Glossary portion of the :term:`app`. Similar to a "plugin" concept; see :doc:`narr/providers/index`. - report - The concept of a report is intentionally vague, in the context of - WuttJamaican. Basically it is something which can be "ran" - (usually with :term:`report params`) to generate a data set. The - output can be viewed in the app UI, or it can be saved to file. - - The base class is :class:`~wuttjamaican.reports.Report`. See - also :term:`report handler`. - - report handler - The :term:`handler` responsible for running :term:`reports - `, for display in app UI or saved to file etc. - - Base class is :class:`~wuttjamaican.reports.ReportHandler`. - - report key - Unique key which identifies a particular :term:`report`. - - report module - This refers to a Python module which contains :term:`report` - definitions. - - report params - This refers to the input parameters used when running a - :term:`report`. It is usually a simple mapping of key/value - pairs. - settings table Table in the :term:`app database` which is used to store :term:`config settings`. See also diff --git a/docs/index.rst b/docs/index.rst index baa26ef..ef8acce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -87,6 +87,5 @@ Contents api/wuttjamaican.install api/wuttjamaican.people api/wuttjamaican.progress - api/wuttjamaican.reports api/wuttjamaican.testing api/wuttjamaican.util diff --git a/pyproject.toml b/pyproject.toml index b5310e5..5a88a56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.20.0" +version = "0.19.3" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index f284f40..c7e0b37 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -88,7 +88,6 @@ class AppHandler: default_email_handler_spec = 'wuttjamaican.email:EmailHandler' default_install_handler_spec = 'wuttjamaican.install:InstallHandler' default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' - default_report_handler_spec = 'wuttjamaican.reports:ReportHandler' def __init__(self, config): self.config = config @@ -772,22 +771,6 @@ class AppHandler: """ return simple_error(error) - def render_percent(self, value, decimals=2): - """ - Return a human-friendly display string for the given - percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``. - - :param value: The value to be rendered. - - :returns: Display string for the percentage value. - """ - if value is None: - return "" - fmt = f'{{:0.{decimals}f}} %' - if value < 0: - return f'({fmt.format(-value)})' - return fmt.format(value) - def render_quantity(self, value, empty_zero=False): """ Return a human-friendly display string for the given quantity @@ -896,19 +879,6 @@ class AppHandler: self.handlers['people'] = factory(self.config, **kwargs) return self.handlers['people'] - def get_report_handler(self, **kwargs): - """ - Get the configured :term:`report handler`. - - :rtype: :class:`~wuttjamaican.reports.ReportHandler` - """ - if 'reports' not in self.handlers: - spec = self.config.get(f'{self.appname}.reports.handler_spec', - default=self.default_report_handler_spec) - factory = self.load_object(spec) - self.handlers['reports'] = factory(self.config, **kwargs) - return self.handlers['reports'] - ############################## # convenience delegators ############################## diff --git a/src/wuttjamaican/reports.py b/src/wuttjamaican/reports.py deleted file mode 100644 index 6d5cb88..0000000 --- a/src/wuttjamaican/reports.py +++ /dev/null @@ -1,228 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2024 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 . -# -################################################################################ -""" -Report Utilities -""" - -import importlib - -from wuttjamaican.app import GenericHandler - - -class Report: - """ - Base class for all :term:`reports `. - - .. attribute:: report_key - - Each report must define a unique key, to identify it. - - .. attribute:: report_title - - This is the common display title for the report. - """ - report_title = "Untitled Report" - - def __init__(self, config): - self.config = config - self.app = config.get_app() - - def add_params(self, schema): - """ - Add field nodes to the given schema, defining all - :term:`report params`. - - :param schema: :class:`~colander:colander.Schema` instance. - - The schema is from Colander so nodes must be compatible with - that; for instance:: - - import colander - - def add_params(self, schema): - - schema.add(colander.SchemaNode( - colander.Date(), - name='start_date')) - - schema.add(colander.SchemaNode( - colander.Date(), - name='end_date')) - """ - - def get_output_columns(self): - """ - This should return a list of column definitions to be used - when displaying or persisting the data output. - - Each entry can be a simple column name, or else a dict with - other options, e.g.:: - - def get_output_columns(self): - return [ - 'foo', - {'name': 'bar', - 'label': "BAR"}, - {'name': 'sales', - 'label': "Total Sales", - 'numeric': True, - 'formatter': self.app.render_currency}, - ] - - :returns: List of column definitions as described above. - - The last entry shown above has all options currently - supported; here we explain those: - - * ``name`` - True name for the column. - - * ``label`` - Display label for the column. If not specified, - one is derived from the ``name``. - - * ``numeric`` - Boolean indicating the column data is numeric, - so should be right-aligned. - - * ``formatter`` - Custom formatter / value rendering callable - for the column. If set, this will be called with just one - arg (the value) for each data row. - """ - raise NotImplementedError - - def make_data(self, params, progress=None): - """ - This must "run" the report and return the final data. - - Note that this should *not* (usually) write the data to file, - its purpose is just to obtain the data. - - The return value should usually be a dict, with no particular - structure required beyond that. However it also can be a list - of data rows. - - There is no default logic here; subclass must define. - - :param params: Dict of :term:`report params`. - - :param progress: Optional progress indicator factory. - - :returns: Data dict, or list of rows. - """ - raise NotImplementedError - - -class ReportHandler(GenericHandler): - """ - Base class and default implementation for the :term:`report - handler`. - """ - - def get_report_modules(self): - """ - Returns a list of all known :term:`report modules `. - - This will discover all report modules exposed by the - :term:`app`, and/or its :term:`providers `. - """ - if not hasattr(self, '_report_modules'): - self._report_modules = [] - for provider in self.app.providers.values(): - if hasattr(provider, 'report_modules'): - modules = provider.report_modules - if modules: - if isinstance(modules, str): - modules = [modules] - for module in modules: - module = importlib.import_module(module) - self._report_modules.append(module) - - return self._report_modules - - def get_reports(self): - """ - Returns a dict of all known :term:`reports `, keyed by - :term:`report key`. - - This calls :meth:`get_report_modules()` and for each module, - it discovers all the reports it contains. - """ - if not hasattr(self, '_reports'): - self._reports = {} - for module in self.get_report_modules(): - for name in dir(module): - obj = getattr(module, name) - if (isinstance(obj, type) - and obj is not Report - and issubclass(obj, Report)): - self._reports[obj.report_key] = obj - - return self._reports - - def get_report(self, key, instance=True): - """ - Fetch the :term:`report` class or instance for given key. - - :param key: Identifying :term:`report key`. - - :param instance: Whether to return the class, or an instance. - Default is ``True`` which means return the instance. - - :returns: :class:`Report` class or instance, or ``None`` if - the report could not be found. - """ - reports = self.get_reports() - if key in reports: - report = reports[key] - if instance: - report = report(self.config) - return report - - def make_report_data(self, report, params=None, progress=None, **kwargs): - """ - Run the given report and return the output data. - - This calls :meth:`Report.make_data()` on the report, and - tweaks the output as needed for consistency. The return value - should resemble this structure:: - - { - 'output_title': "My Report", - 'data': ..., - } - - However that is the *minimum*; the dict may have other keys as - well. - - :param report: :class:`Report` instance to run. - - :param params: Dict of :term:`report params`. - - :param progress: Optional progress indicator factory. - - :returns: Data dict with structure shown above. - """ - data = report.make_data(params or {}, progress=progress, **kwargs) - if not isinstance(data, dict): - data = {'data': data} - data.setdefault('output_title', report.report_title) - return data diff --git a/tests/__init__.py b/tests/__init__.py index 1bbf4c1..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -# nb. some tests require this to be a true package diff --git a/tests/test_app.py b/tests/test_app.py index 0176cfb..7c0f963 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -491,22 +491,6 @@ app_title = WuttaTest result = self.app.render_error(error) self.assertEqual(result, "RuntimeError") - def test_render_percent(self): - - # null - self.assertEqual(self.app.render_percent(None), "") - - # typical - self.assertEqual(self.app.render_percent(12.3419), '12.34 %') - - # more decimal places - self.assertEqual(self.app.render_percent(12.3419, decimals=3), '12.342 %') - self.assertEqual(self.app.render_percent(12.3419, decimals=4), '12.3419 %') - - # negative - self.assertEqual(self.app.render_percent(-12.3419), '(12.34 %)') - self.assertEqual(self.app.render_percent(-12.3419, decimals=3), '(12.342 %)') - def test_render_quantity(self): # null @@ -573,12 +557,6 @@ app_title = WuttaTest people = self.app.get_people_handler() self.assertIsInstance(people, PeopleHandler) - def test_get_report_handler(self): - from wuttjamaican.reports import ReportHandler - - handler = self.app.get_report_handler() - self.assertIsInstance(handler, ReportHandler) - def test_send_email(self): from wuttjamaican.email import EmailHandler diff --git a/tests/test_email.py b/tests/test_email.py index 1776723..d21fa32 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -151,7 +151,7 @@ class TestEmailHandler(ConfigTestCase): self.assertEqual(len(modules), 1) self.assertIs(modules[0], mod) - # provider may specify modules as string + # provider may specify modules as list providers = { 'wuttatest': MagicMock(email_modules='wuttjamaican.email'), } diff --git a/tests/test_reports.py b/tests/test_reports.py deleted file mode 100644 index 7b19385..0000000 --- a/tests/test_reports.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch, MagicMock - -from wuttjamaican import reports as mod -from wuttjamaican.testing import ConfigTestCase - - -class MockFooReport(mod.Report): - report_key = 'mock_foo' - report_title = "MOCK Report" - - def make_data(self, params, **kwargs): - return [ - {'foo': 'bar'}, - ] - - -class TestReport(ConfigTestCase): - - def test_get_output_columns(self): - report = mod.Report(self.config) - self.assertRaises(NotImplementedError, report.get_output_columns) - - def test_make_data(self): - report = mod.Report(self.config) - self.assertRaises(NotImplementedError, report.make_data, {}) - - -class TestReportHandler(ConfigTestCase): - - def make_handler(self): - return mod.ReportHandler(self.config) - - def test_get_report_modules(self): - - # no providers, no report modules - with patch.object(self.app, 'providers', new={}): - handler = self.make_handler() - self.assertEqual(handler.get_report_modules(), []) - - # provider may specify modules as list - providers = { - 'wuttatest': MagicMock(report_modules=['wuttjamaican.reports']), - } - with patch.object(self.app, 'providers', new=providers): - handler = self.make_handler() - modules = handler.get_report_modules() - self.assertEqual(len(modules), 1) - self.assertIs(modules[0], mod) - - # provider may specify modules as string - providers = { - 'wuttatest': MagicMock(report_modules='wuttjamaican.reports'), - } - with patch.object(self.app, 'providers', new=providers): - handler = self.make_handler() - modules = handler.get_report_modules() - self.assertEqual(len(modules), 1) - self.assertIs(modules[0], mod) - - def test_get_reports(self): - - # no providers, no reports - with patch.object(self.app, 'providers', new={}): - handler = self.make_handler() - self.assertEqual(handler.get_reports(), {}) - - # provider may define reports (via modules) - providers = { - 'wuttatest': MagicMock(report_modules=['tests.test_reports']), - } - with patch.object(self.app, 'providers', new=providers): - handler = self.make_handler() - reports = handler.get_reports() - self.assertEqual(len(reports), 1) - self.assertIn('mock_foo', reports) - - def test_get_report(self): - providers = { - 'wuttatest': MagicMock(report_modules=['tests.test_reports']), - } - - with patch.object(self.app, 'providers', new=providers): - handler = self.make_handler() - - # as instance - report = handler.get_report('mock_foo') - self.assertIsInstance(report, mod.Report) - self.assertIsInstance(report, MockFooReport) - - # as class - report = handler.get_report('mock_foo', instance=False) - self.assertTrue(issubclass(report, mod.Report)) - self.assertIs(report, MockFooReport) - - def test_make_report_data(self): - providers = { - 'wuttatest': MagicMock(report_modules=['tests.test_reports']), - } - - with patch.object(self.app, 'providers', new=providers): - handler = self.make_handler() - report = handler.get_report('mock_foo') - - data = handler.make_report_data(report) - self.assertEqual(len(data), 2) - self.assertIn('output_title', data) - self.assertEqual(data['output_title'], "MOCK Report") - self.assertIn('data', data) - self.assertEqual(data['data'], [{'foo': 'bar'}])