Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/app.py: 100%
294 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-15 17:02 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-15 17:02 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24WuttJamaican - app handler
25"""
27import datetime
28import importlib
29import os
30import sys
31import warnings
33import humanize
35from wuttjamaican.util import (load_entry_points, load_object,
36 make_title, make_full_name, make_uuid, make_true_uuid,
37 progress_loop, resource_path, simple_error)
40class AppHandler:
41 """
42 Base class and default implementation for top-level :term:`app
43 handler`.
45 aka. "the handler to handle all handlers"
47 aka. "one handler to bind them all"
49 For more info see :doc:`/narr/handlers/app`.
51 There is normally no need to create one of these yourself; rather
52 you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()`
53 on the :term:`config object` if you need the app handler.
55 :param config: Config object for the app. This should be an
56 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
58 .. attribute:: model
60 Reference to the :term:`app model` module.
62 Note that :meth:`get_model()` is responsible for determining
63 which module this will point to. However you can always get
64 the model using this attribute (e.g. ``app.model``) and do not
65 need to call :meth:`get_model()` yourself - that part will
66 happen automatically.
68 .. attribute:: enum
70 Reference to the :term:`app enum` module.
72 Note that :meth:`get_enum()` is responsible for determining
73 which module this will point to. However you can always get
74 the model using this attribute (e.g. ``app.enum``) and do not
75 need to call :meth:`get_enum()` yourself - that part will
76 happen automatically.
78 .. attribute:: providers
80 Dictionary of :class:`AppProvider` instances, as returned by
81 :meth:`get_all_providers()`.
82 """
83 default_app_title = "WuttJamaican"
84 default_model_spec = 'wuttjamaican.db.model'
85 default_enum_spec = 'wuttjamaican.enum'
86 default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
87 default_db_handler_spec = 'wuttjamaican.db.handler:DatabaseHandler'
88 default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
89 default_install_handler_spec = 'wuttjamaican.install:InstallHandler'
90 default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
91 default_report_handler_spec = 'wuttjamaican.reports:ReportHandler'
93 def __init__(self, config):
94 self.config = config
95 self.handlers = {}
97 @property
98 def appname(self):
99 """
100 The :term:`app name` for the current app. This is just an
101 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`.
103 Note that this ``appname`` does not necessariy reflect what
104 you think of as the name of your (e.g. custom) app. It is
105 more fundamental than that; your Python package naming and the
106 :term:`app title` are free to use a different name as their
107 basis.
108 """
109 return self.config.appname
111 def __getattr__(self, name):
112 """
113 Custom attribute getter, called when the app handler does not
114 already have an attribute with the given ``name``.
116 This will delegate to the set of :term:`app providers<app
117 provider>`; the first provider with an appropriately-named
118 attribute wins, and that value is returned.
120 :returns: The first value found among the set of app
121 providers.
122 """
124 if name == 'model':
125 return self.get_model()
127 if name == 'enum':
128 return self.get_enum()
130 if name == 'providers':
131 self.providers = self.get_all_providers()
132 return self.providers
134 for provider in self.providers.values():
135 if hasattr(provider, name):
136 return getattr(provider, name)
138 raise AttributeError(f"attr not found: {name}")
140 def get_all_providers(self):
141 """
142 Load and return all registered providers.
144 Note that you do not need to call this directly; instead just
145 use :attr:`providers`.
147 The discovery logic is based on :term:`entry points<entry
148 point>` using the ``wutta.app.providers`` group. For instance
149 here is a sample entry point used by WuttaWeb (in its
150 ``pyproject.toml``):
152 .. code-block:: toml
154 [project.entry-points."wutta.app.providers"]
155 wuttaweb = "wuttaweb.app:WebAppProvider"
157 :returns: Dictionary keyed by entry point name; values are
158 :class:`AppProvider` instances.
159 """
160 # nb. must use 'wutta' and not self.appname prefix here, or
161 # else we can't find all providers with custom appname
162 providers = load_entry_points('wutta.app.providers')
163 for key in list(providers):
164 providers[key] = providers[key](self.config)
165 return providers
167 def get_title(self, default=None):
168 """
169 Returns the configured title for the app.
171 :param default: Value to be returned if there is no app title
172 configured.
174 :returns: Title for the app.
175 """
176 return self.config.get(f'{self.appname}.app_title',
177 default=default or self.default_app_title)
179 def get_node_title(self, default=None):
180 """
181 Returns the configured title for the local app node.
183 If none is configured, and no default provided, will return
184 the value from :meth:`get_title()`.
186 :param default: Value to use if the node title is not
187 configured.
189 :returns: Title for the local app node.
190 """
191 title = self.config.get(f'{self.appname}.node_title')
192 if title:
193 return title
194 return self.get_title(default=default)
196 def get_node_type(self, default=None):
197 """
198 Returns the "type" of current app node.
200 The framework itself does not (yet?) have any notion of what a
201 node type means. This abstraction is here for convenience, in
202 case it is needed by a particular app ecosystem.
204 :returns: String name for the node type, or ``None``.
206 The node type must be configured via file; this cannot be done
207 with a DB setting. Depending on :attr:`appname` that is like
208 so:
210 .. code-block:: ini
212 [wutta]
213 node_type = warehouse
214 """
215 return self.config.get(f'{self.appname}.node_type', default=default,
216 usedb=False)
218 def get_distribution(self, obj=None):
219 """
220 Returns the appropriate Python distribution name.
222 If ``obj`` is specified, this will attempt to locate the
223 distribution based on the top-level module which contains the
224 object's type/class.
226 If ``obj`` is *not* specified, this behaves a bit differently.
227 It first will look for a :term:`config setting` named
228 ``wutta.app_dist`` (or similar, dpending on :attr:`appname`).
229 If there is such a config value, it is returned. Otherwise
230 the "auto-locate" logic described above happens, but using
231 ``self`` instead of ``obj``.
233 In other words by default this returns the distribution to
234 which the running :term:`app handler` belongs.
236 See also :meth:`get_version()`.
238 :param obj: Any object which may be used as a clue to locate
239 the appropriate distribution.
241 :returns: string, or ``None``
243 Also note that a *distribution* name is different from a
244 *package* name. The distribution name is how things appear on
245 PyPI for instance.
247 If you want to override the default distribution name (and
248 skip the auto-locate based on app handler) then you can define
249 it in config:
251 .. code-block:: ini
253 [wutta]
254 app_dist = My-Poser-Dist
255 """
256 if obj is None:
257 dist = self.config.get(f'{self.appname}.app_dist')
258 if dist:
259 return dist
261 # TODO: do we need a config setting for app_package ?
262 #modpath = self.config.get(f'{self.appname}.app_package')
263 modpath = None
264 if not modpath:
265 modpath = type(obj if obj is not None else self).__module__
266 pkgname = modpath.split('.')[0]
268 try:
269 from importlib.metadata import packages_distributions
270 except ImportError: # python < 3.10
271 from importlib_metadata import packages_distributions
273 pkgmap = packages_distributions()
274 if pkgname in pkgmap:
275 dist = pkgmap[pkgname][0]
276 return dist
278 # fall back to configured dist, if obj lookup failed
279 if obj is not None:
280 return self.config.get(f'{self.appname}.app_dist')
282 def get_version(self, dist=None, obj=None):
283 """
284 Returns the version of a given Python distribution.
286 If ``dist`` is not specified, calls :meth:`get_distribution()`
287 to get it. (It passes ``obj`` along for this).
289 So by default this will return the version of whichever
290 distribution owns the running :term:`app handler`.
292 :returns: Version as string.
293 """
294 from importlib.metadata import version
296 if not dist:
297 dist = self.get_distribution(obj=obj)
298 if dist:
299 return version(dist)
301 def get_model(self):
302 """
303 Returns the :term:`app model` module.
305 Note that you don't actually need to call this method; you can
306 get the model by simply accessing :attr:`model`
307 (e.g. ``app.model``) instead.
309 By default this will return :mod:`wuttjamaican.db.model`
310 unless the config class or some :term:`config extension` has
311 provided another default.
313 A custom app can override the default like so (within a config
314 extension)::
316 config.setdefault('wutta.model_spec', 'poser.db.model')
317 """
318 if 'model' not in self.__dict__:
319 spec = self.config.get(f'{self.appname}.model_spec',
320 usedb=False,
321 default=self.default_model_spec)
322 self.model = importlib.import_module(spec)
323 return self.model
325 def get_enum(self):
326 """
327 Returns the :term:`app enum` module.
329 Note that you don't actually need to call this method; you can
330 get the module by simply accessing :attr:`enum`
331 (e.g. ``app.enum``) instead.
333 By default this will return :mod:`wuttjamaican.enum` unless
334 the config class or some :term:`config extension` has provided
335 another default.
337 A custom app can override the default like so (within a config
338 extension)::
340 config.setdefault('wutta.enum_spec', 'poser.enum')
341 """
342 if 'enum' not in self.__dict__:
343 spec = self.config.get(f'{self.appname}.enum_spec',
344 usedb=False,
345 default=self.default_enum_spec)
346 self.enum = importlib.import_module(spec)
347 return self.enum
349 def load_object(self, spec):
350 """
351 Import and/or load and return the object designated by the
352 given spec string.
354 This invokes :func:`wuttjamaican.util.load_object()`.
356 :param spec: String of the form ``module.dotted.path:objname``.
358 :returns: The object referred to by ``spec``. If the module
359 could not be imported, or did not contain an object of the
360 given name, then an error will raise.
361 """
362 return load_object(spec)
364 def get_appdir(self, *args, **kwargs):
365 """
366 Returns path to the :term:`app dir`.
368 This does not check for existence of the path, it only reads
369 it from config or (optionally) provides a default path.
371 :param configured_only: Pass ``True`` here if you only want
372 the configured path and ignore the default path.
374 :param create: Pass ``True`` here if you want to ensure the
375 returned path exists, creating it if necessary.
377 :param \*args: Any additional args will be added as child
378 paths for the final value.
380 For instance, assuming ``/srv/envs/poser`` is the virtual
381 environment root::
383 app.get_appdir() # => /srv/envs/poser/app
385 app.get_appdir('data') # => /srv/envs/poser/app/data
386 """
387 configured_only = kwargs.pop('configured_only', False)
388 create = kwargs.pop('create', False)
390 # maybe specify default path
391 if not configured_only:
392 path = os.path.join(sys.prefix, 'app')
393 kwargs.setdefault('default', path)
395 # get configured path
396 kwargs.setdefault('usedb', False)
397 path = self.config.get(f'{self.appname}.appdir', **kwargs)
399 # add any subpath info
400 if path and args:
401 path = os.path.join(path, *args)
403 # create path if requested/needed
404 if create:
405 if not path:
406 raise ValueError("appdir path unknown! so cannot create it.")
407 if not os.path.exists(path):
408 os.makedirs(path)
410 return path
412 def make_appdir(self, path, subfolders=None, **kwargs):
413 """
414 Establish an :term:`app dir` at the given path.
416 Default logic only creates a few subfolders, meant to help
417 steer the admin toward a convention for sake of where to put
418 things. But custom app handlers are free to do whatever.
420 :param path: Path to the desired app dir. If the path does
421 not yet exist then it will be created. But regardless it
422 should be "refreshed" (e.g. missing subfolders created)
423 when this method is called.
425 :param subfolders: Optional list of subfolder names to create
426 within the app dir. If not specified, defaults will be:
427 ``['cache', 'data', 'log', 'work']``.
428 """
429 appdir = path
430 if not os.path.exists(appdir):
431 os.makedirs(appdir)
433 if not subfolders:
434 subfolders = ['cache', 'data', 'log', 'work']
436 for name in subfolders:
437 path = os.path.join(appdir, name)
438 if not os.path.exists(path):
439 os.mkdir(path)
441 def render_mako_template(
442 self,
443 template,
444 context,
445 output_path=None,
446 ):
447 """
448 Convenience method to render a Mako template.
450 :param template: :class:`~mako:mako.template.Template`
451 instance.
453 :param context: Dict of context for the template.
455 :param output_path: Optional path to which output should be
456 written.
458 :returns: Rendered output as string.
459 """
460 output = template.render(**context)
461 if output_path:
462 with open(output_path, 'wt') as f:
463 f.write(output)
464 return output
466 def resource_path(self, path):
467 """
468 Convenience wrapper for
469 :func:`wuttjamaican.util.resource_path()`.
470 """
471 return resource_path(path)
473 def make_session(self, **kwargs):
474 """
475 Creates a new SQLAlchemy session for the app DB. By default
476 this will create a new :class:`~wuttjamaican.db.sess.Session`
477 instance.
479 :returns: SQLAlchemy session for the app DB.
480 """
481 from .db import Session
483 return Session(**kwargs)
485 def make_title(self, text, **kwargs):
486 """
487 Return a human-friendly "title" for the given text.
489 This is mostly useful for converting a Python variable name (or
490 similar) to a human-friendly string, e.g.::
492 make_title('foo_bar') # => 'Foo Bar'
494 By default this just invokes
495 :func:`wuttjamaican.util.make_title()`.
496 """
497 return make_title(text)
499 def make_full_name(self, *parts):
500 """
501 Make a "full name" from the given parts.
503 This is a convenience wrapper around
504 :func:`~wuttjamaican.util.make_full_name()`.
505 """
506 return make_full_name(*parts)
508 def make_true_uuid(self):
509 """
510 Generate a new UUID value.
512 By default this simply calls
513 :func:`wuttjamaican.util.make_true_uuid()`.
515 :returns: :class:`python:uuid.UUID` instance
517 .. warning::
519 For now, callers should use this method when they want a
520 proper UUID instance, whereas :meth:`make_uuid()` will
521 always return a string.
523 However once all dependent logic has been refactored to
524 support proper UUID data type, then ``make_uuid()`` will
525 return those and this method will eventually be removed.
526 """
527 return make_true_uuid()
529 def make_uuid(self):
530 """
531 Generate a new UUID value.
533 By default this simply calls
534 :func:`wuttjamaican.util.make_uuid()`.
536 :returns: UUID value as 32-character string.
538 .. warning::
540 For now, this method always returns a string.
542 However once all dependent logic has been refactored to
543 support proper UUID data type, then this method will return
544 those and the :meth:`make_true_uuid()` method will
545 eventually be removed.
546 """
547 return make_uuid()
549 def progress_loop(self, *args, **kwargs):
550 """
551 Convenience method to iterate over a set of items, invoking
552 logic for each, and updating a progress indicator along the
553 way.
555 This is a wrapper around
556 :func:`wuttjamaican.util.progress_loop()`; see those docs for
557 param details.
558 """
559 return progress_loop(*args, **kwargs)
561 def get_session(self, obj):
562 """
563 Returns the SQLAlchemy session with which the given object is
564 associated. Simple convenience wrapper around
565 :func:`sqlalchemy:sqlalchemy.orm.object_session()`.
566 """
567 from sqlalchemy import orm
569 return orm.object_session(obj)
571 def short_session(self, **kwargs):
572 """
573 Returns a context manager for a short-lived database session.
575 This is a convenience wrapper around
576 :class:`~wuttjamaican.db.sess.short_session`.
578 If caller does not specify ``factory`` nor ``config`` params,
579 this method will provide a default factory in the form of
580 :meth:`make_session`.
581 """
582 from .db import short_session
584 if 'factory' not in kwargs and 'config' not in kwargs:
585 kwargs['factory'] = self.make_session
587 return short_session(**kwargs)
589 def get_setting(self, session, name, **kwargs):
590 """
591 Get a :term:`config setting` value from the DB.
593 This does *not* consult the :term:`config object` directly to
594 determine the setting value; it always queries the DB.
596 Default implementation is just a convenience wrapper around
597 :func:`~wuttjamaican.db.conf.get_setting()`.
599 See also :meth:`save_setting()` and :meth:`delete_setting()`.
601 :param session: App DB session.
603 :param name: Name of the setting to get.
605 :returns: Setting value as string, or ``None``.
606 """
607 from .db import get_setting
609 return get_setting(session, name)
611 def save_setting(
612 self,
613 session,
614 name,
615 value,
616 force_create=False,
617 ):
618 """
619 Save a :term:`config setting` value to the DB.
621 See also :meth:`get_setting()` and :meth:`delete_setting()`.
623 :param session: Current :term:`db session`.
625 :param name: Name of the setting to save.
627 :param value: Value to be saved for the setting; should be
628 either a string or ``None``.
630 :param force_create: If ``False`` (the default) then logic
631 will first try to locate an existing setting of the same
632 name, and update it if found, or create if not.
634 But if this param is ``True`` then logic will only try to
635 create a new record, and not bother checking to see if it
636 exists.
638 (Theoretically the latter offers a slight efficiency gain.)
639 """
640 model = self.model
642 # maybe fetch existing setting
643 setting = None
644 if not force_create:
645 setting = session.get(model.Setting, name)
647 # create setting if needed
648 if not setting:
649 setting = model.Setting(name=name)
650 session.add(setting)
652 # set value
653 setting.value = value
655 def delete_setting(self, session, name):
656 """
657 Delete a :term:`config setting` from the DB.
659 See also :meth:`get_setting()` and :meth:`save_setting()`.
661 :param session: Current :term:`db session`.
663 :param name: Name of the setting to delete.
664 """
665 model = self.model
666 setting = session.get(model.Setting, name)
667 if setting:
668 session.delete(setting)
670 def continuum_is_enabled(self):
671 """
672 Returns boolean indicating if Wutta-Continuum is installed and
673 enabled.
675 Default will be ``False`` as enabling it requires additional
676 installation and setup. For instructions see
677 :doc:`wutta-continuum:narr/install`.
678 """
679 for provider in self.providers.values():
680 if hasattr(provider, 'continuum_is_enabled'):
681 return provider.continuum_is_enabled()
683 return False
685 ##############################
686 # common value renderers
687 ##############################
689 def render_boolean(self, value):
690 """
691 Render a boolean value for display.
693 :param value: A boolean, or ``None``.
695 :returns: Display string for the value.
696 """
697 if value is None:
698 return ''
700 return "Yes" if value else "No"
702 def render_currency(self, value, scale=2):
703 """
704 Return a human-friendly display string for the given currency
705 value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
707 :param value: Either a :class:`python:decimal.Decimal` or
708 :class:`python:float` value.
710 :param scale: Number of decimal digits to be displayed.
712 :returns: Display string for the value.
713 """
714 if value is None:
715 return ''
717 if value < 0:
718 fmt = f"(${{:0,.{scale}f}})"
719 return fmt.format(0 - value)
721 fmt = f"${{:0,.{scale}f}}"
722 return fmt.format(value)
724 display_format_date = '%Y-%m-%d'
725 """
726 Format string to use when displaying :class:`python:datetime.date`
727 objects. See also :meth:`render_date()`.
728 """
730 display_format_datetime = '%Y-%m-%d %H:%M%z'
731 """
732 Format string to use when displaying
733 :class:`python:datetime.datetime` objects. See also
734 :meth:`render_datetime()`.
735 """
737 def render_date(self, value):
738 """
739 Return a human-friendly display string for the given date.
741 Uses :attr:`display_format_date` to render the value.
743 :param value: A :class:`python:datetime.date` instance (or
744 ``None``).
746 :returns: Display string.
747 """
748 if value is None:
749 return ""
750 return value.strftime(self.display_format_date)
752 def render_datetime(self, value):
753 """
754 Return a human-friendly display string for the given datetime.
756 Uses :attr:`display_format_datetime` to render the value.
758 :param value: A :class:`python:datetime.datetime` instance (or
759 ``None``).
761 :returns: Display string.
762 """
763 if value is None:
764 return ""
765 return value.strftime(self.display_format_datetime)
767 def render_error(self, error):
768 """
769 Return a "human-friendly" display string for the error, e.g.
770 when showing it to the user.
772 By default, this is a convenience wrapper for
773 :func:`~wuttjamaican.util.simple_error()`.
774 """
775 return simple_error(error)
777 def render_percent(self, value, decimals=2):
778 """
779 Return a human-friendly display string for the given
780 percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
782 :param value: The value to be rendered.
784 :returns: Display string for the percentage value.
785 """
786 if value is None:
787 return ""
788 fmt = f'{{:0.{decimals}f}} %'
789 if value < 0:
790 return f'({fmt.format(-value)})'
791 return fmt.format(value)
793 def render_quantity(self, value, empty_zero=False):
794 """
795 Return a human-friendly display string for the given quantity
796 value, e.g. ``1.000`` becomes ``"1"``.
798 :param value: The quantity to be rendered.
800 :param empty_zero: Affects the display when value equals zero.
801 If false (the default), will return ``'0'``; if true then
802 it returns empty string.
804 :returns: Display string for the quantity.
805 """
806 if value is None:
807 return ''
808 if int(value) == value:
809 value = int(value)
810 if empty_zero and value == 0:
811 return ''
812 return str(value)
813 return str(value).rstrip('0')
815 def render_time_ago(self, value):
816 """
817 Return a human-friendly string, indicating how long ago
818 something occurred.
820 Default logic uses :func:`humanize:humanize.naturaltime()` for
821 the rendering.
823 :param value: Instance of :class:`python:datetime.datetime` or
824 :class:`python:datetime.timedelta`.
826 :returns: Text to display.
827 """
828 return humanize.naturaltime(value)
830 ##############################
831 # getters for other handlers
832 ##############################
834 def get_auth_handler(self, **kwargs):
835 """
836 Get the configured :term:`auth handler`.
838 :rtype: :class:`~wuttjamaican.auth.AuthHandler`
839 """
840 if 'auth' not in self.handlers:
841 spec = self.config.get(f'{self.appname}.auth.handler',
842 default=self.default_auth_handler_spec)
843 factory = self.load_object(spec)
844 self.handlers['auth'] = factory(self.config, **kwargs)
845 return self.handlers['auth']
847 def get_batch_handler(self, key, default=None, **kwargs):
848 """
849 Get the configured :term:`batch handler` for the given type.
851 :param key: Unique key designating the :term:`batch type`.
853 :param default: Spec string to use as the default, if none is
854 configured.
856 :returns: :class:`~wuttjamaican.batch.BatchHandler` instance
857 for the requested type. If no spec can be determined, a
858 ``KeyError`` is raised.
859 """
860 spec = self.config.get(f'{self.appname}.batch.{key}.handler.spec',
861 default=default)
862 if not spec:
863 spec = self.config.get(f'{self.appname}.batch.{key}.handler.default_spec')
864 if not spec:
865 raise KeyError(f"handler spec not found for batch key: {key}")
866 factory = self.load_object(spec)
867 return factory(self.config, **kwargs)
869 def get_batch_handler_specs(self, key, default=None):
870 """
871 Get the :term:`spec` strings for all available handlers of the
872 given batch type.
874 :param key: Unique key designating the :term:`batch type`.
876 :param default: Default spec string(s) to include, even if not
877 registered. Can be a string or list of strings.
879 :returns: List of batch handler spec strings.
881 This will gather available spec strings from the following:
883 First, the ``default`` as provided by caller.
885 Second, the default spec from config, if set; for example:
887 .. code-block:: ini
889 [wutta.batch]
890 inventory.handler.default_spec = poser.batch.inventory:InventoryBatchHandler
892 Third, each spec registered via entry points. For instance in
893 ``pyproject.toml``:
895 .. code-block:: toml
897 [project.entry-points."wutta.batch.inventory"]
898 poser = "poser.batch.inventory:InventoryBatchHandler"
900 The final list will be "sorted" according to the above, with
901 the latter registered handlers being sorted alphabetically.
902 """
903 handlers = []
905 # defaults from caller
906 if isinstance(default, str):
907 handlers.append(default)
908 elif default:
909 handlers.extend(default)
911 # configured default, if applicable
912 default = self.config.get(f'{self.config.appname}.batch.{key}.handler.default_spec')
913 if default and default not in handlers:
914 handlers.append(default)
916 # registered via entry points
917 registered = []
918 for Handler in load_entry_points(f'{self.appname}.batch.{key}').values():
919 spec = Handler.get_spec()
920 if spec not in handlers:
921 registered.append(spec)
922 if registered:
923 registered.sort()
924 handlers.extend(registered)
926 return handlers
928 def get_db_handler(self, **kwargs):
929 """
930 Get the configured :term:`db handler`.
932 :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler`
933 """
934 if 'db' not in self.handlers:
935 spec = self.config.get(f'{self.appname}.db.handler',
936 default=self.default_db_handler_spec)
937 factory = self.load_object(spec)
938 self.handlers['db'] = factory(self.config, **kwargs)
939 return self.handlers['db']
941 def get_email_handler(self, **kwargs):
942 """
943 Get the configured :term:`email handler`.
945 See also :meth:`send_email()`.
947 :rtype: :class:`~wuttjamaican.email.EmailHandler`
948 """
949 if 'email' not in self.handlers:
950 spec = self.config.get(f'{self.appname}.email.handler',
951 default=self.default_email_handler_spec)
952 factory = self.load_object(spec)
953 self.handlers['email'] = factory(self.config, **kwargs)
954 return self.handlers['email']
956 def get_install_handler(self, **kwargs):
957 """
958 Get the configured :term:`install handler`.
960 :rtype: :class:`~wuttjamaican.install.handler.InstallHandler`
961 """
962 if 'install' not in self.handlers:
963 spec = self.config.get(f'{self.appname}.install.handler',
964 default=self.default_install_handler_spec)
965 factory = self.load_object(spec)
966 self.handlers['install'] = factory(self.config, **kwargs)
967 return self.handlers['install']
969 def get_people_handler(self, **kwargs):
970 """
971 Get the configured "people" :term:`handler`.
973 :rtype: :class:`~wuttjamaican.people.PeopleHandler`
974 """
975 if 'people' not in self.handlers:
976 spec = self.config.get(f'{self.appname}.people.handler',
977 default=self.default_people_handler_spec)
978 factory = self.load_object(spec)
979 self.handlers['people'] = factory(self.config, **kwargs)
980 return self.handlers['people']
982 def get_report_handler(self, **kwargs):
983 """
984 Get the configured :term:`report handler`.
986 :rtype: :class:`~wuttjamaican.reports.ReportHandler`
987 """
988 if 'reports' not in self.handlers:
989 spec = self.config.get(f'{self.appname}.reports.handler_spec',
990 default=self.default_report_handler_spec)
991 factory = self.load_object(spec)
992 self.handlers['reports'] = factory(self.config, **kwargs)
993 return self.handlers['reports']
995 ##############################
996 # convenience delegators
997 ##############################
999 def get_person(self, obj, **kwargs):
1000 """
1001 Convenience method to locate a
1002 :class:`~wuttjamaican.db.model.base.Person` for the given
1003 object.
1005 This delegates to the "people" handler method,
1006 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
1007 """
1008 return self.get_people_handler().get_person(obj, **kwargs)
1010 def send_email(self, *args, **kwargs):
1011 """
1012 Send an email message.
1014 This is a convenience wrapper around
1015 :meth:`~wuttjamaican.email.EmailHandler.send_email()`.
1016 """
1017 self.get_email_handler().send_email(*args, **kwargs)
1020class AppProvider:
1021 """
1022 Base class for :term:`app providers<app provider>`.
1024 These can add arbitrary extra functionality to the main :term:`app
1025 handler`. See also :doc:`/narr/providers/app`.
1027 :param config: The app :term:`config object`.
1029 ``AppProvider`` instances have the following attributes:
1031 .. attribute:: config
1033 Reference to the config object.
1035 .. attribute:: app
1037 Reference to the parent app handler.
1039 Some things which a subclass may define, in order to register
1040 various features with the app:
1042 .. attribute:: email_modules
1044 List of :term:`email modules <email module>` provided. Should
1045 be a list of strings; each is a dotted module path, e.g.::
1047 email_modules = ['poser.emails']
1049 .. attribute:: email_templates
1051 List of :term:`email template` folders provided. Can be a list
1052 of paths, or a single path as string::
1054 email_templates = ['poser:templates/email']
1056 email_templates = 'poser:templates/email'
1058 Note the syntax, which specifies python module, then colon
1059 (``:``), then filesystem path below that. However absolute
1060 file paths may be used as well, when applicable.
1061 """
1063 def __init__(self, config):
1065 if isinstance(config, AppHandler):
1066 warnings.warn("passing app handler to app provider is deprecated; "
1067 "must pass config object instead",
1068 DeprecationWarning, stacklevel=2)
1069 config = config.config
1071 self.config = config
1072 self.app = self.config.get_app()
1074 @property
1075 def appname(self):
1076 """
1077 The :term:`app name` for the current app.
1079 See also :attr:`AppHandler.appname`.
1080 """
1081 return self.app.appname
1084class GenericHandler:
1085 """
1086 Generic base class for handlers.
1088 When the :term:`app` defines a new *type* of :term:`handler` it
1089 may subclass this when defining the handler base class.
1091 :param config: Config object for the app. This should be an
1092 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
1093 """
1095 def __init__(self, config):
1096 self.config = config
1097 self.app = self.config.get_app()
1099 @property
1100 def appname(self):
1101 """
1102 The :term:`app name` for the current app.
1104 See also :attr:`AppHandler.appname`.
1105 """
1106 return self.app.appname
1108 @classmethod
1109 def get_spec(cls):
1110 """
1111 Returns the class :term:`spec` string for the handler.
1112 """
1113 return f'{cls.__module__}:{cls.__name__}'