diff --git a/docs/api/rattail/db/index.rst b/docs/api/rattail/db/index.rst index bf112a6d..a78dbdb9 100644 --- a/docs/api/rattail/db/index.rst +++ b/docs/api/rattail/db/index.rst @@ -27,6 +27,7 @@ model.batch.product model.batch.purchase model.batch.vendorcatalog + model.core model.customers model.datasync model.labels diff --git a/docs/api/rattail/db/model.core.rst b/docs/api/rattail/db/model.core.rst new file mode 100644 index 00000000..3f8319f4 --- /dev/null +++ b/docs/api/rattail/db/model.core.rst @@ -0,0 +1,33 @@ + +``rattail.db.model.core`` +========================= + +.. automodule:: rattail.db.model.core + +.. autoclass:: ModelBase + + .. attribute:: model_title + + Optionally set this to a "humanized" version of the model name, for + display in templates etc. Default value will be guessed from the model + class name, e.g. 'Product' => "Products" and 'CustomerOrder' => "Customer + Order". + + .. attribute:: model_title_plural + + Optionally set this to a "humanized" version of the *plural* model name, + for display in templates etc. Default value will be guessed from the + model class name, e.g. 'Product' => "Products" and 'CustomerOrder' => + "Customer Orders". + +.. autoclass:: Setting + :members: + +.. autoclass:: Change + :members: + +.. automodule:: rattail.db.model.contact + :members: + +.. todo:: + Geez a lot of work left here... diff --git a/docs/api/rattail/db/model.rst b/docs/api/rattail/db/model.rst index 96197b23..a8373780 100644 --- a/docs/api/rattail/db/model.rst +++ b/docs/api/rattail/db/model.rst @@ -2,32 +2,4 @@ ``rattail.db.model`` ==================== -.. automodule:: rattail.db.model.core - -.. autoclass:: ModelBase - - .. attribute:: model_title - - Optionally set this to a "humanized" version of the model name, for - display in templates etc. Default value will be guessed from the model - class name, e.g. 'Product' => "Products" and 'CustomerOrder' => "Customer - Order". - - .. attribute:: model_title_plural - - Optionally set this to a "humanized" version of the *plural* model name, - for display in templates etc. Default value will be guessed from the - model class name, e.g. 'Product' => "Products" and 'CustomerOrder' => - "Customer Orders". - -.. autoclass:: Setting - :members: - -.. autoclass:: Change - :members: - -.. automodule:: rattail.db.model.contact - :members: - -.. todo:: - Geez a lot of work left here... +.. automodule:: rattail.db.model diff --git a/rattail/_version.py b/rattail/_version.py index 36c23508..446041e2 100644 --- a/rattail/_version.py +++ b/rattail/_version.py @@ -2,7 +2,7 @@ try: from importlib.metadata import version -except ImportError: +except ImportError: # pragma: no cover from importlib_metadata import version diff --git a/rattail/config.py b/rattail/config.py index 94c10862..27bba4e3 100644 --- a/rattail/config.py +++ b/rattail/config.py @@ -36,6 +36,7 @@ from wuttjamaican.conf import (WuttaConfig, WuttaConfigExtension, generic_default_files) from wuttjamaican.util import (parse_bool as wutta_parse_bool, parse_list as wutta_parse_list) +from wuttjamaican.exc import ConfigurationError as WuttaConfigurationError from rattail.exceptions import WindowsExtensionsNotInstalled, ConfigurationError @@ -44,13 +45,7 @@ log = logging.getLogger(__name__) def parse_bool(value): # pragma: no cover - """ - Compatibility wrapper for - :func:`wuttjamaican:wuttjamaican.util.parse_bool()`. - - This function is deprecated; new code should use the upstream - function instead. - """ + """ """ warnings.warn("rattail.config.parse_bool() is deprecated; " "please use wuttjamaican.util.parse_bool() instead", DeprecationWarning, stacklevel=2) @@ -58,13 +53,7 @@ def parse_bool(value): # pragma: no cover def parse_list(value): # pragma: no cover - """ - Compatibility wrapper for - :func:`wuttjamaican:wuttjamaican.util.parse_list()`. - - This function is deprecated; new code should use the upstream - function instead. - """ + """ """ warnings.warn("rattail.config.parse_list() is deprecated; " "please use wuttjamaican.util.parse_list() instead", DeprecationWarning, stacklevel=2) @@ -120,7 +109,8 @@ class RattailConfig(WuttaConfig): normally expects just ``(key, value)`` args, but we also (for now) support the older style of ``(section, option, value)`` - *eventually* that will go away but probably not in the near - future. + future. However new code should pass ``(key, value)`` since + that is now the preferred signature. """ # figure out what sort of args were passed if len(args) == 2: @@ -143,7 +133,8 @@ class RattailConfig(WuttaConfig): normally expects just ``(key, ...)`` args, but we also (for now) support the older style of ``(section, option, ...)`` - *eventually* that will go away but probably not in the near - future. + future. However new code should pass ``(key, ...)`` since + that is now the preferred signature. """ # figure out what sort of args were passed if len(args) == 1: @@ -171,6 +162,30 @@ class RattailConfig(WuttaConfig): # DeprecationWarning, stacklevel=2) return self.get_bool(*args, **kwargs) + def get_date(self, *args, **kwargs): + """ + Retrieve a date value from config. + + Accepts same params as :meth:`get()` but if a value is found, + it will be coerced to date via + :meth:`rattail.app.AppHandler.parse_date()`. + """ + value = self.get(*args, **kwargs) + app = self.get_app() + return app.parse_date(value) + + def getdate(self, *args, **kwargs): + """ + Backward-compatible alias for :meth:`get_date()`. + + New code should use ``get_date()`` instead of this method. + """ + # TODO: eventually + # warnings.warn("config.getbool() method is deprecated; " + # "please use config.get_bool() instead", + # DeprecationWarning, stacklevel=2) + return self.get_date(*args, **kwargs) + def getint(self, *args, **kwargs): """ Backward-compatible alias for @@ -201,30 +216,14 @@ class RattailConfig(WuttaConfig): """ Convenience method around the :func:`~wuttjamaican:wuttjamaican.util.parse_bool()` function. - - Usage of this method is discouraged, at least until some more - dust settles. This probably belongs on the app handler - instead so it can be overridden more easily. """ - # TODO: eventually - # warnings.warn("config.parse_bool() method is deprecated; " - # "please use app.parse_bool() instead", - # DeprecationWarning, stacklevel=2) return wutta_parse_bool(value) def parse_list(self, value): """ Convenience method around the :func:`~wuttjamaican:wuttjamaican.util.parse_list()` function. - - Usage of this method is discouraged, at least until some more - dust settles. This probably belongs on the app handler - instead so it can be overridden more easily. """ - # TODO: eventually - # warnings.warn("config.parse_list() method is deprecated; " - # "please use app.parse_list() instead", - # DeprecationWarning, stacklevel=2) return wutta_parse_list(value) def make_list_string(self, values): @@ -254,7 +253,7 @@ class RattailConfig(WuttaConfig): def beaker_invalidate_setting(self, name): """ - Backward-compatible method for unused Beaker caching logic. + Deprecated method for unused Beaker caching logic. This method has no effect and should not be used. """ @@ -262,18 +261,6 @@ class RattailConfig(WuttaConfig): # warnings.warn("config.beaker_invalidate_setting() method is deprecated", # DeprecationWarning, stacklevel=2) - def node_type(self, default=None): - """ - Returns the "type" of current node. What this means will - generally depend on the app logic. - """ - try: - return self.require('rattail', 'node_type', usedb=False) - except ConfigurationError: - if default: - return default - raise - def production(self): """ Returns boolean indicating whether the app is running in @@ -281,10 +268,23 @@ class RattailConfig(WuttaConfig): """ return self.getbool('rattail', 'production', default=False) + def node_type(self, default=None): + """ + Returns the "type" of current node as string. What this means + will generally depend on the app logic. There is no default + node type unless caller provides one. + """ + try: + return self.require('rattail', 'node_type', usedb=False) + except WuttaConfigurationError: + if default: + return default + raise + def get_model(self): """ - Returns a reference to configured 'model' module; defaults to - :mod:`rattail.db.model`. + Returns a reference to the module containing data models; + defaults to :mod:`rattail.db.model`. """ spec = self.get('rattail', 'model', usedb=False, default='rattail.db.model') @@ -303,7 +303,8 @@ class RattailConfig(WuttaConfig): """ Returns a reference to the configured data 'model' module for Trainwreck. Note that there is *not* a default value for - this; it must be configured. + this; it must be configured or else calling this method will + result in an error. """ spec = self.require('rattail.trainwreck', 'model', usedb=False) return importlib.import_module(spec) @@ -315,29 +316,15 @@ class RattailConfig(WuttaConfig): return self.getbool('rattail.db', 'versioning.enabled', usedb=False, default=False) - def getdate(self, *args, **kwargs): - """ - Retrieve a date value from config. - """ - value = self.get(*args, **kwargs) - app = self.get_app() - return app.parse_date(value) - - def product_key(self, **kwargs): - """ - Deprecated; instead please see - :meth:`rattail.app.AppHandler.get_product_key_field()`. - """ + def product_key(self, **kwargs): # pragma: no cover + """ """ warnings.warn("config.product_key() is deprecated; please " "use app.get_product_key_field() instead", DeprecationWarning, stacklevel=2) return self.get_app().get_product_key_field() - def product_key_title(self, key=None): - """ - Deprecated; instead please see - :meth:`rattail.app.AppHandler.get_product_key_label()`. - """ + def product_key_title(self, key=None): # pragma: no cover + """ """ warnings.warn("config.product_key_title() is deprecated; please " "use app.get_product_key_label() instead", DeprecationWarning, stacklevel=2) @@ -352,14 +339,20 @@ class RattailConfig(WuttaConfig): return self.get('rattail', 'app_package', default=default) def app_title(self, **kwargs): - """ DEPRECATED """ + """ + DEPRECATED; use :meth:`rattail.app.AppHandler.get_title()` + instead. + """ # TODO: should put a deprecation warning here, but it could # make things noisy for a while and i'm not ready for that app = self.get_app() return app.get_title(**kwargs) def node_title(self, **kwargs): - """ DEPRECATED """ + """ + DEPRECATED; use + :meth:`rattail.app.AppHandler.get_node_title()` instead. + """ # TODO: should put a deprecation warning here, but it could # make things noisy for a while and i'm not ready for that app = self.get_app() @@ -369,18 +362,28 @@ class RattailConfig(WuttaConfig): """ Returns boolean indicating whether the app is running from source, as opposed to official release. + + .. warning:: + + The utility of this method is questionable and it ideally + will go away in the future. """ return self.getbool('rattail', 'running_from_source', default=False) def demo(self): """ Returns boolean indicating whether the app is running in demo mode + + .. warning:: + + The utility of this method is questionable and it ideally + will go away in the future. """ return self.getbool('rattail', 'demo', default=False) def appdir(self, require=True, **kwargs): """ - Returns path to the 'app' dir, if known. + Returns path to the local 'app' dir. """ if require: path = os.path.join(sys.prefix, 'app') @@ -405,7 +408,7 @@ class RattailConfig(WuttaConfig): def batch_filedir(self, key=None): """ Returns path to root folder where batches (optionally of type - 'key') are stored. + ``key``) are stored. """ path = os.path.abspath(self.require('rattail', 'batch.files')) if key: diff --git a/rattail/db/model/__init__.py b/rattail/db/model/__init__.py index ff1e3da9..ee524dd3 100644 --- a/rattail/db/model/__init__.py +++ b/rattail/db/model/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -22,6 +22,9 @@ ################################################################################ """ Rattail data models + +This namespace will be populated with all data model classes defined +by Rattail. """ from .core import Base, ModelBase, uuid_column, getset_factory, GPCType, Setting, Change, Note diff --git a/tests/test_config.py b/tests/test_config.py index e28c9a0e..69e37796 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,120 +3,17 @@ import configparser import datetime import os +import sys from unittest import TestCase from unittest.mock import patch from wuttjamaican.testing import FileConfigTestCase +from wuttjamaican.exc import ConfigurationError from rattail import config from rattail.app import AppHandler -class TestRattailConfig(FileConfigTestCase): - - def setup_files(self): - self.site_path = self.write_file('site.conf', """ -[rattail] - """) - - self.host_path = self.write_file('host.conf', """ -[rattail.config] -include = "{}" - """.format(self.site_path)) - - self.app_path = self.write_file('app.conf', """ -[rattail.config] -include = "{}" - """.format(self.host_path)) - - self.custom_path = self.write_file('custom.conf', """ -[rattail.config] -include = "%(here)s/app.conf" - """) - - def test_init_defaults(self): - cfg = config.RattailConfig() - self.assertEqual(cfg.files_requested, []) - self.assertEqual(cfg.files_read, []) - - def test_init_params(self): - self.setup_files() - - # files - cfg = config.RattailConfig() - self.assertEqual(cfg.files_requested, []) - self.assertEqual(cfg.files_read, []) - cfg = config.RattailConfig(files=[self.site_path]) - self.assertEqual(cfg.files_requested, [self.site_path]) - self.assertEqual(cfg.files_read, [self.site_path]) - - # usedb - cfg = config.RattailConfig() - self.assertFalse(cfg.usedb) - cfg = config.RattailConfig(usedb=True) - self.assertTrue(cfg.usedb) - - # preferdb - cfg = config.RattailConfig() - self.assertFalse(cfg.preferdb) - cfg = config.RattailConfig(preferdb=True) - self.assertTrue(cfg.preferdb) - - def test_read_file_with_recurse(self): - self.setup_files() - cfg = config.RattailConfig() - cfg.read_file(self.custom_path, recurse=True) - self.assertEqual(cfg.files_requested, [self.custom_path, self.app_path, self.host_path, self.site_path]) - self.assertEqual(cfg.files_read, [self.site_path, self.host_path, self.app_path, self.custom_path]) - - def test_read_file_once_only(self): - self.setup_files() - - another_path = self.write_file('another.conf', """ -[rattail.config] -include = "{custom}" "{site}" "{app}" "{site}" "{custom}" - """.format(custom=self.custom_path, app=self.app_path, site=self.site_path)) - - cfg = config.RattailConfig() - cfg.read_file(another_path, recurse=True) - self.assertEqual(cfg.files_requested, [another_path, self.custom_path, self.app_path, self.host_path, self.site_path]) - self.assertEqual(cfg.files_read, [self.site_path, self.host_path, self.app_path, self.custom_path, another_path]) - - def test_read_file_skip_missing(self): - self.setup_files() - bogus_path = '/tmp/does-not/exist' - self.assertFalse(os.path.exists(bogus_path)) - - another_path = self.write_file('another.conf', """ -[rattail.config] -include = "{bogus}" "{app}" "{bogus}" "{site}" - """.format(bogus=bogus_path, app=self.app_path, site=self.site_path)) - - cfg = config.RattailConfig() - cfg.read_file(another_path, recurse=True) - self.assertEqual(cfg.files_requested, [another_path, bogus_path, self.app_path, self.host_path, self.site_path]) - self.assertEqual(cfg.files_read, [self.site_path, self.host_path, self.app_path, another_path]) - - @patch('rattail.config.logging.config.fileConfig') - def test_configure_logging(self, fileConfig): - cfg = config.RattailConfig() - - # logging not configured by default - cfg.configure_logging() - self.assertFalse(fileConfig.called) - - # but config option can enable it - cfg.set('rattail.config', 'configure_logging', 'true') - cfg.configure_logging() - self.assertEqual(fileConfig.call_count, 1) - fileConfig.reset_mock() - - # invalid logging config is ignored - fileConfig.side_effect = configparser.NoSectionError('loggers') - cfg.configure_logging() - self.assertEqual(fileConfig.call_count, 1) - - class TestRattailConfig(FileConfigTestCase): def test_prioritized_files(self): @@ -135,7 +32,6 @@ require = %(here)s/first.conf self.assertEqual(len(files), 2) self.assertEqual(files[0], second) self.assertEqual(files[1], first) - self.assertIs(files, myconfig.get_prioritized_files()) def test_setdefault(self): myconfig = config.RattailConfig() @@ -164,12 +60,42 @@ require = %(here)s/first.conf # try that for get() too self.assertRaises(ValueError, myconfig.get, 'foo', 'bar', 'blarg', 'blast') + def test_get(self): + myconfig = config.RattailConfig() + myconfig.setdefault('foo.bar', 'baz') + + # can pass section + option + self.assertEqual(myconfig.get('foo', 'bar'), 'baz') + + # or can pass just a key + self.assertEqual(myconfig.get('foo.bar'), 'baz') + + # so 1 or 2 args required, otherwise error + self.assertRaises(ValueError, myconfig.get, 'foo', 'bar', 'baz') + self.assertRaises(ValueError, myconfig.get) + def test_getbool(self): myconfig = config.RattailConfig() self.assertFalse(myconfig.getbool('foo.bar')) myconfig.setdefault('foo.bar', 'true') self.assertTrue(myconfig.getbool('foo.bar')) + def test_get_date(self): + myconfig = config.RattailConfig() + self.assertIsNone(myconfig.get_date('foo.date')) + myconfig.setdefault('foo.date', '2023-11-20') + value = myconfig.get_date('foo.date') + self.assertIsInstance(value, datetime.date) + self.assertEqual(value, datetime.date(2023, 11, 20)) + + def test_getdate(self): + myconfig = config.RattailConfig() + self.assertIsNone(myconfig.getdate('foo.date')) + myconfig.setdefault('foo.date', '2023-11-20') + value = myconfig.getdate('foo.date') + self.assertIsInstance(value, datetime.date) + self.assertEqual(value, datetime.date(2023, 11, 20)) + def test_getint(self): myconfig = config.RattailConfig() self.assertIsNone(myconfig.getint('foo.bar')) @@ -182,20 +108,6 @@ require = %(here)s/first.conf myconfig.setdefault('foo.bar', 'hello world') self.assertEqual(myconfig.getlist('foo.bar'), ['hello', 'world']) - def test_getdate(self): - myconfig = config.RattailConfig() - self.assertIsNone(myconfig.getdate('foo.date')) - myconfig.setdefault('foo.date', '2023-11-20') - value = myconfig.getdate('foo.date') - self.assertIsInstance(value, datetime.date) - self.assertEqual(value, datetime.date(2023, 11, 20)) - - def test_get_app(self): - myconfig = config.RattailConfig() - app = myconfig.get_app() - self.assertIsInstance(app, AppHandler) - self.assertIs(type(app), AppHandler) - def test_parse_bool(self): myconfig = config.RattailConfig() self.assertTrue(myconfig.parse_bool('true')) @@ -218,11 +130,195 @@ require = %(here)s/first.conf value = myconfig.make_list_string(["you don't", 'say']) self.assertEqual(value, "\"you don't\", say") + def test_get_app(self): + myconfig = config.RattailConfig() + app = myconfig.get_app() + self.assertIsInstance(app, AppHandler) + self.assertIs(type(app), AppHandler) + def test_beaker_invalidate_setting(self): # TODO: this doesn't really test anything, just gives coverage myconfig = config.RattailConfig() myconfig.beaker_invalidate_setting('foo') + def test_production(self): + myconfig = config.RattailConfig() + + # false if not defined + self.assertFalse(myconfig.production()) + + # but config may specify + myconfig.setdefault('rattail.production', 'true') + self.assertTrue(myconfig.production()) + + def test_node_type(self): + myconfig = config.RattailConfig() + + # error if node type not defined + self.assertRaises(ConfigurationError, myconfig.node_type) + + # unless default is provided + self.assertEqual(myconfig.node_type(default='foo'), 'foo') + + # or config contains the definition + myconfig.setdefault('rattail.node_type', 'bar') + self.assertEqual(myconfig.node_type(), 'bar') + + def test_get_model(self): + myconfig = config.RattailConfig() + + # default is rattail.db.model + model = myconfig.get_model() + self.assertIs(model, sys.modules['rattail.db.model']) + + # or config may specify + myconfig.setdefault('rattail.model', 'rattail.trainwreck.db.model') + model = myconfig.get_model() + self.assertIs(model, sys.modules['rattail.trainwreck.db.model']) + + def test_get_enum(self): + myconfig = config.RattailConfig() + + # default is rattail.enum + enum = myconfig.get_enum() + self.assertIs(enum, sys.modules['rattail.enum']) + + # or config may specify + # (nb. using bogus example module here) + myconfig.setdefault('rattail.enum', 'rattail.auth') + enum = myconfig.get_enum() + self.assertIs(enum, sys.modules['rattail.auth']) + + def test_get_trainwreck_model(self): + myconfig = config.RattailConfig() + + # error if not defined + self.assertRaises(ConfigurationError, myconfig.get_trainwreck_model) + + # but config may specify + myconfig.setdefault('rattail.trainwreck.model', 'rattail.trainwreck.db.model') + model = myconfig.get_trainwreck_model() + self.assertIs(model, sys.modules['rattail.trainwreck.db.model']) + + def test_versioning_enabled(self): + myconfig = config.RattailConfig() + + # false by default + self.assertFalse(myconfig.versioning_enabled()) + + # but config may enable + myconfig.setdefault('rattail.db.versioning.enabled', 'true') + self.assertTrue(myconfig.versioning_enabled()) + + def test_app_package(self): + myconfig = config.RattailConfig() + + # error if not defined + self.assertRaises(ConfigurationError, myconfig.app_package) + + # unless default is provided + self.assertEqual(myconfig.app_package(default='foo'), 'foo') + + # but config may specify + myconfig.setdefault('rattail.app_package', 'bar') + self.assertEqual(myconfig.app_package(), 'bar') + + def test_app_title(self): + myconfig = config.RattailConfig() + + # default title + self.assertEqual(myconfig.app_title(), 'Rattail') + + # but config may specify + myconfig.setdefault('rattail.app_title', 'Foo') + self.assertEqual(myconfig.app_title(), 'Foo') + + def test_node_title(self): + myconfig = config.RattailConfig() + + # default title + self.assertEqual(myconfig.app_title(), 'Rattail') + + # but config may specify + myconfig.setdefault('rattail.node_title', 'Foo (node)') + self.assertEqual(myconfig.node_title(), 'Foo (node)') + + def test_running_from_source(self): + myconfig = config.RattailConfig() + + # false by default + self.assertFalse(myconfig.running_from_source()) + + # but config may enable + myconfig.setdefault('rattail.running_from_source', 'true') + self.assertTrue(myconfig.running_from_source()) + + def test_demo(self): + myconfig = config.RattailConfig() + + # false by default + self.assertFalse(myconfig.demo()) + + # but config may enable + myconfig.setdefault('rattail.demo', 'true') + self.assertTrue(myconfig.demo()) + + def test_appdir(self): + myconfig = config.RattailConfig() + + # can be none if required is false + self.assertIsNone(myconfig.appdir(require=False)) + + # otherwise sane fallback is used + with patch('rattail.config.sys') as sys: + sys.prefix = 'foo' + path = os.path.join('foo', 'app') + self.assertEqual(myconfig.appdir(), path) + + # or config may specify + myconfig.setdefault('rattail.appdir', '/foo/bar/baz') + self.assertEqual(myconfig.appdir(), '/foo/bar/baz') + + def test_datadir(self): + myconfig = config.RattailConfig() + + # error if not defined + self.assertRaises(ConfigurationError, myconfig.datadir) + + # but can avoid error if not required + self.assertIsNone(myconfig.datadir(require=False)) + + # or config may specify + myconfig.setdefault('rattail.datadir', '/foo/bar/baz') + self.assertEqual(myconfig.datadir(), '/foo/bar/baz') + + def test_workdir(self): + myconfig = config.RattailConfig() + + # error if not defined + self.assertRaises(ConfigurationError, myconfig.workdir) + + # but can avoid error if not required + self.assertIsNone(myconfig.workdir(require=False)) + + # or config may specify + myconfig.setdefault('rattail.workdir', '/foo/bar/baz') + self.assertEqual(myconfig.workdir(), '/foo/bar/baz') + + def test_batch_filedir(self): + myconfig = config.RattailConfig() + + # error if not defined + self.assertRaises(ConfigurationError, myconfig.batch_filedir) + + # config may specify + path = os.path.join(os.sep, 'foo', 'files') + myconfig.setdefault('rattail.batch.files', path) + self.assertEqual(myconfig.batch_filedir(), path) + + # caller may specify a key + self.assertEqual(myconfig.batch_filedir(key='bar'), os.path.join(path, 'bar')) + class TestRattailDefaultFiles(FileConfigTestCase):