Compare commits
	
		
			No commits in common. "ce79346f76c11d0473bcf7378d05870cef850b64" and "1bfab90d35a6e5739c824f8f0d830b2156e7eb7f" have entirely different histories.
		
	
	
		
			ce79346f76
			...
			1bfab90d35
		
	
		
					 11 changed files with 2 additions and 439 deletions
				
			
		
							
								
								
									
										11
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										11
									
								
								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 | ||||
|  |  | |||
|  | @ -1,6 +0,0 @@ | |||
| 
 | ||||
| ``wuttjamaican.reports`` | ||||
| ======================== | ||||
| 
 | ||||
| .. automodule:: wuttjamaican.reports | ||||
|    :members: | ||||
|  | @ -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 | ||||
|      <report>`, 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<config setting>`.  See also | ||||
|  |  | |||
|  | @ -87,6 +87,5 @@ Contents | |||
|    api/wuttjamaican.install | ||||
|    api/wuttjamaican.people | ||||
|    api/wuttjamaican.progress | ||||
|    api/wuttjamaican.reports | ||||
|    api/wuttjamaican.testing | ||||
|    api/wuttjamaican.util | ||||
|  |  | |||
|  | @ -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"}] | ||||
|  |  | |||
|  | @ -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 | ||||
|     ############################## | ||||
|  |  | |||
|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| # | ||||
| ################################################################################ | ||||
| """ | ||||
| Report Utilities | ||||
| """ | ||||
| 
 | ||||
| import importlib | ||||
| 
 | ||||
| from wuttjamaican.app import GenericHandler | ||||
| 
 | ||||
| 
 | ||||
| class Report: | ||||
|     """ | ||||
|     Base class for all :term:`reports <report>`. | ||||
| 
 | ||||
|     .. 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 <report | ||||
|         module>`. | ||||
| 
 | ||||
|         This will discover all report modules exposed by the | ||||
|         :term:`app`, and/or its :term:`providers <provider>`. | ||||
|         """ | ||||
|         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 <report>`, 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 | ||||
|  | @ -1 +0,0 @@ | |||
| # nb. some tests require this to be a true package | ||||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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'), | ||||
|         } | ||||
|  |  | |||
|  | @ -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'}]) | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue