Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/app.py: 100%
358 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-25 15:39 -0600
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-25 15:39 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2025 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"""
26# pylint: disable=too-many-lines
28import datetime
29import logging
30import os
31import sys
32import warnings
33import importlib
34from importlib.metadata import version
36import humanize
37from webhelpers2.html import HTML
39from wuttjamaican.util import (
40 get_timezone_by_name,
41 get_value,
42 localtime,
43 load_entry_points,
44 load_object,
45 make_title,
46 make_full_name,
47 make_utc,
48 make_uuid,
49 make_str_uuid,
50 make_true_uuid,
51 progress_loop,
52 resource_path,
53 simple_error,
54)
57log = logging.getLogger(__name__)
60class AppHandler: # pylint: disable=too-many-public-methods
61 """
62 Base class and default implementation for top-level :term:`app
63 handler`.
65 aka. "the handler to handle all handlers"
67 aka. "one handler to bind them all"
69 For more info see :doc:`/narr/handlers/app`.
71 There is normally no need to create one of these yourself; rather
72 you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()`
73 on the :term:`config object` if you need the app handler.
75 :param config: Config object for the app. This should be an
76 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
78 .. attribute:: model
80 Reference to the :term:`app model` module.
82 Note that :meth:`get_model()` is responsible for determining
83 which module this will point to. However you can always get
84 the model using this attribute (e.g. ``app.model``) and do not
85 need to call :meth:`get_model()` yourself - that part will
86 happen automatically.
88 .. attribute:: enum
90 Reference to the :term:`app enum` module.
92 Note that :meth:`get_enum()` is responsible for determining
93 which module this will point to. However you can always get
94 the model using this attribute (e.g. ``app.enum``) and do not
95 need to call :meth:`get_enum()` yourself - that part will
96 happen automatically.
98 .. attribute:: providers
100 Dictionary of :class:`AppProvider` instances, as returned by
101 :meth:`get_all_providers()`.
102 """
104 default_app_title = "WuttJamaican"
105 default_model_spec = "wuttjamaican.db.model"
106 default_enum_spec = "wuttjamaican.enum"
107 default_auth_handler_spec = "wuttjamaican.auth:AuthHandler"
108 default_db_handler_spec = "wuttjamaican.db.handler:DatabaseHandler"
109 default_email_handler_spec = "wuttjamaican.email:EmailHandler"
110 default_install_handler_spec = "wuttjamaican.install:InstallHandler"
111 default_people_handler_spec = "wuttjamaican.people:PeopleHandler"
112 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler"
113 default_report_handler_spec = "wuttjamaican.reports:ReportHandler"
115 def __init__(self, config):
116 self.config = config
117 self.handlers = {}
118 self.timezones = {}
120 @property
121 def appname(self):
122 """
123 The :term:`app name` for the current app. This is just an
124 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`.
126 Note that this ``appname`` does not necessariy reflect what
127 you think of as the name of your (e.g. custom) app. It is
128 more fundamental than that; your Python package naming and the
129 :term:`app title` are free to use a different name as their
130 basis.
131 """
132 return self.config.appname
134 def __getattr__(self, name):
135 """
136 Custom attribute getter, called when the app handler does not
137 already have an attribute with the given ``name``.
139 This will delegate to the set of :term:`app providers<app
140 provider>`; the first provider with an appropriately-named
141 attribute wins, and that value is returned.
143 :returns: The first value found among the set of app
144 providers.
145 """
147 if name == "model":
148 return self.get_model()
150 if name == "enum":
151 return self.get_enum()
153 if name == "providers":
154 self.__dict__["providers"] = self.get_all_providers()
155 return self.providers
157 for provider in self.providers.values():
158 if hasattr(provider, name):
159 return getattr(provider, name)
161 raise AttributeError(f"attr not found: {name}")
163 def get_all_providers(self):
164 """
165 Load and return all registered providers.
167 Note that you do not need to call this directly; instead just
168 use :attr:`providers`.
170 The discovery logic is based on :term:`entry points<entry
171 point>` using the ``wutta.app.providers`` group. For instance
172 here is a sample entry point used by WuttaWeb (in its
173 ``pyproject.toml``):
175 .. code-block:: toml
177 [project.entry-points."wutta.app.providers"]
178 wuttaweb = "wuttaweb.app:WebAppProvider"
180 :returns: Dictionary keyed by entry point name; values are
181 :class:`AppProvider` instances.
182 """
183 # nb. must use 'wutta' and not self.appname prefix here, or
184 # else we can't find all providers with custom appname
185 providers = load_entry_points("wutta.app.providers")
186 for key in list(providers):
187 providers[key] = providers[key](self.config)
188 return providers
190 def get_title(self, default=None):
191 """
192 Returns the configured title for the app.
194 :param default: Value to be returned if there is no app title
195 configured.
197 :returns: Title for the app.
198 """
199 return self.config.get(
200 f"{self.appname}.app_title", default=default or self.default_app_title
201 )
203 def get_node_title(self, default=None):
204 """
205 Returns the configured title for the local app node.
207 If none is configured, and no default provided, will return
208 the value from :meth:`get_title()`.
210 :param default: Value to use if the node title is not
211 configured.
213 :returns: Title for the local app node.
214 """
215 title = self.config.get(f"{self.appname}.node_title")
216 if title:
217 return title
218 return self.get_title(default=default)
220 def get_node_type(self, default=None):
221 """
222 Returns the "type" of current app node.
224 The framework itself does not (yet?) have any notion of what a
225 node type means. This abstraction is here for convenience, in
226 case it is needed by a particular app ecosystem.
228 :returns: String name for the node type, or ``None``.
230 The node type must be configured via file; this cannot be done
231 with a DB setting. Depending on :attr:`appname` that is like
232 so:
234 .. code-block:: ini
236 [wutta]
237 node_type = warehouse
238 """
239 return self.config.get(
240 f"{self.appname}.node_type", default=default, usedb=False
241 )
243 def get_distribution(self, obj=None):
244 """
245 Returns the appropriate Python distribution name.
247 If ``obj`` is specified, this will attempt to locate the
248 distribution based on the top-level module which contains the
249 object's type/class.
251 If ``obj`` is *not* specified, this behaves a bit differently.
252 It first will look for a :term:`config setting` named
253 ``wutta.app_dist`` (or similar, depending on :attr:`appname`).
254 If there is such a config value, it is returned. Otherwise
255 the "auto-locate" logic described above happens, but using
256 ``self`` instead of ``obj``.
258 In other words by default this returns the distribution to
259 which the running :term:`app handler` belongs.
261 See also :meth:`get_version()`.
263 :param obj: Any object which may be used as a clue to locate
264 the appropriate distribution.
266 :returns: string, or ``None``
268 Also note that a *distribution* name is different from a
269 *package* name. The distribution name is how things appear on
270 PyPI for instance.
272 If you want to override the default distribution name (and
273 skip the auto-locate based on app handler) then you can define
274 it in config:
276 .. code-block:: ini
278 [wutta]
279 app_dist = My-Poser-Dist
280 """
281 if obj is None:
282 dist = self.config.get(f"{self.appname}.app_dist")
283 if dist:
284 return dist
286 # TODO: do we need a config setting for app_package ?
287 # modpath = self.config.get(f'{self.appname}.app_package')
288 modpath = None
289 if not modpath:
290 modpath = type(obj if obj is not None else self).__module__
291 pkgname = modpath.split(".")[0]
293 try:
294 from importlib.metadata import ( # pylint: disable=import-outside-toplevel
295 packages_distributions,
296 )
297 except ImportError: # python < 3.10
298 from importlib_metadata import ( # pylint: disable=import-outside-toplevel
299 packages_distributions,
300 )
302 pkgmap = packages_distributions()
303 if pkgname in pkgmap:
304 dist = pkgmap[pkgname][0]
305 return dist
307 # fall back to configured dist, if obj lookup failed
308 return self.config.get(f"{self.appname}.app_dist")
310 def get_version(self, dist=None, obj=None):
311 """
312 Returns the version of a given Python distribution.
314 If ``dist`` is not specified, calls :meth:`get_distribution()`
315 to get it. (It passes ``obj`` along for this).
317 So by default this will return the version of whichever
318 distribution owns the running :term:`app handler`.
320 :returns: Version as string.
321 """
322 if not dist:
323 dist = self.get_distribution(obj=obj)
324 if dist:
325 return version(dist)
326 return None
328 def get_model(self):
329 """
330 Returns the :term:`app model` module.
332 Note that you don't actually need to call this method; you can
333 get the model by simply accessing :attr:`model`
334 (e.g. ``app.model``) instead.
336 By default this will return :mod:`wuttjamaican.db.model`
337 unless the config class or some :term:`config extension` has
338 provided another default.
340 A custom app can override the default like so (within a config
341 extension)::
343 config.setdefault('wutta.model_spec', 'poser.db.model')
344 """
345 if "model" not in self.__dict__:
346 spec = self.config.get(
347 f"{self.appname}.model_spec",
348 usedb=False,
349 default=self.default_model_spec,
350 )
351 self.__dict__["model"] = importlib.import_module(spec)
352 return self.model
354 def get_enum(self):
355 """
356 Returns the :term:`app enum` module.
358 Note that you don't actually need to call this method; you can
359 get the module by simply accessing :attr:`enum`
360 (e.g. ``app.enum``) instead.
362 By default this will return :mod:`wuttjamaican.enum` unless
363 the config class or some :term:`config extension` has provided
364 another default.
366 A custom app can override the default like so (within a config
367 extension)::
369 config.setdefault('wutta.enum_spec', 'poser.enum')
370 """
371 if "enum" not in self.__dict__:
372 spec = self.config.get(
373 f"{self.appname}.enum_spec", usedb=False, default=self.default_enum_spec
374 )
375 self.__dict__["enum"] = importlib.import_module(spec)
376 return self.enum
378 def get_value(self, obj, key):
379 """
380 Convenience wrapper around
381 :func:`wuttjamaican.util.get_value()`.
383 :param obj: Arbitrary dict or object of any kind which would
384 have named attributes.
386 :param key: Key/name of the field to get.
388 :returns: Whatever value is found. Or maybe an
389 ``AttributeError`` is raised if the object does not have
390 the key/attr set.
391 """
392 return get_value(obj, key)
394 def load_object(self, spec):
395 """
396 Import and/or load and return the object designated by the
397 given spec string.
399 This invokes :func:`wuttjamaican.util.load_object()`.
401 :param spec: String of the form ``module.dotted.path:objname``.
403 :returns: The object referred to by ``spec``. If the module
404 could not be imported, or did not contain an object of the
405 given name, then an error will raise.
406 """
407 return load_object(spec)
409 def get_appdir(self, *args, **kwargs):
410 """
411 Returns path to the :term:`app dir`.
413 This does not check for existence of the path, it only reads
414 it from config or (optionally) provides a default path.
416 :param configured_only: Pass ``True`` here if you only want
417 the configured path and ignore the default path.
419 :param create: Pass ``True`` here if you want to ensure the
420 returned path exists, creating it if necessary.
422 :param \\*args: Any additional args will be added as child
423 paths for the final value.
425 For instance, assuming ``/srv/envs/poser`` is the virtual
426 environment root::
428 app.get_appdir() # => /srv/envs/poser/app
430 app.get_appdir('data') # => /srv/envs/poser/app/data
431 """
432 configured_only = kwargs.pop("configured_only", False)
433 create = kwargs.pop("create", False)
435 # maybe specify default path
436 if not configured_only:
437 path = os.path.join(sys.prefix, "app")
438 kwargs.setdefault("default", path)
440 # get configured path
441 kwargs.setdefault("usedb", False)
442 path = self.config.get(f"{self.appname}.appdir", **kwargs)
444 # add any subpath info
445 if path and args:
446 path = os.path.join(path, *args)
448 # create path if requested/needed
449 if create:
450 if not path:
451 raise ValueError("appdir path unknown! so cannot create it.")
452 if not os.path.exists(path):
453 os.makedirs(path)
455 return path
457 def make_appdir(self, path, subfolders=None):
458 """
459 Establish an :term:`app dir` at the given path.
461 Default logic only creates a few subfolders, meant to help
462 steer the admin toward a convention for sake of where to put
463 things. But custom app handlers are free to do whatever.
465 :param path: Path to the desired app dir. If the path does
466 not yet exist then it will be created. But regardless it
467 should be "refreshed" (e.g. missing subfolders created)
468 when this method is called.
470 :param subfolders: Optional list of subfolder names to create
471 within the app dir. If not specified, defaults will be:
472 ``['cache', 'data', 'log', 'work']``.
473 """
474 appdir = path
475 if not os.path.exists(appdir):
476 os.makedirs(appdir)
478 if not subfolders:
479 subfolders = ["cache", "data", "log", "work"]
481 for name in subfolders:
482 path = os.path.join(appdir, name)
483 if not os.path.exists(path):
484 os.mkdir(path)
486 def render_mako_template(
487 self,
488 template,
489 context,
490 output_path=None,
491 ):
492 """
493 Convenience method to render a Mako template.
495 :param template: :class:`~mako:mako.template.Template`
496 instance.
498 :param context: Dict of context for the template.
500 :param output_path: Optional path to which output should be
501 written.
503 :returns: Rendered output as string.
504 """
505 output = template.render(**context)
506 if output_path:
507 with open(output_path, "wt", encoding="utf_8") as f:
508 f.write(output)
509 return output
511 def resource_path(self, path):
512 """
513 Convenience wrapper for
514 :func:`wuttjamaican.util.resource_path()`.
515 """
516 return resource_path(path)
518 def make_session(self, **kwargs):
519 """
520 Creates a new SQLAlchemy session for the app DB. By default
521 this will create a new :class:`~wuttjamaican.db.sess.Session`
522 instance.
524 :returns: SQLAlchemy session for the app DB.
525 """
526 from .db import Session # pylint: disable=import-outside-toplevel
528 return Session(**kwargs)
530 def make_title(self, text):
531 """
532 Return a human-friendly "title" for the given text.
534 This is mostly useful for converting a Python variable name (or
535 similar) to a human-friendly string, e.g.::
537 make_title('foo_bar') # => 'Foo Bar'
539 By default this just invokes
540 :func:`wuttjamaican.util.make_title()`.
541 """
542 return make_title(text)
544 def make_full_name(self, *parts):
545 """
546 Make a "full name" from the given parts.
548 This is a convenience wrapper around
549 :func:`~wuttjamaican.util.make_full_name()`.
550 """
551 return make_full_name(*parts)
553 def get_timezone(self, key="default"):
554 """
555 Get the configured (or system default) timezone object.
557 This checks config for a setting which corresponds to the
558 given ``key``, then calls
559 :func:`~wuttjamaican.util.get_timezone_by_name()` to get the
560 actual timezone object.
562 The default key corresponds to the true "local" timezone, but
563 other keys may correspond to other configured timezones (if
564 applicable).
566 As a special case for the default key only: If no config value
567 is found, Python itself will determine the default system
568 local timezone.
570 For any non-default key, an error is raised if no config value
571 is found.
573 .. note::
575 The app handler *caches* all timezone objects, to avoid
576 unwanted repetitive lookups when processing multiple
577 datetimes etc. (Since this method is called by
578 :meth:`localtime()`.) Therefore whenever timezone config
579 values are changed, an app restart will be necessary.
581 Example config:
583 .. code-block:: ini
585 [wutta]
586 timezone.default = America/Chicago
587 timezone.westcoast = America/Los_Angeles
589 Example usage::
591 tz_default = app.get_timezone()
592 tz_westcoast = app.get_timezone("westcoast")
594 See also :meth:`get_timezone_name()`.
596 :param key: Config key for desired timezone.
598 :returns: :class:`python:datetime.tzinfo` instance
599 """
600 if key not in self.timezones:
601 setting = f"{self.appname}.timezone.{key}"
602 tzname = self.config.get(setting)
603 if tzname:
604 self.timezones[key] = get_timezone_by_name(tzname)
606 elif key == "default":
607 # fallback to system default
608 self.timezones[key] = datetime.datetime.now().astimezone().tzinfo
610 else:
611 # alternate key was specified, but no config found, so check
612 # again with require() to force error
613 self.timezones[key] = self.config.require(setting)
615 return self.timezones[key]
617 def get_timezone_name(self, key="default"):
618 """
619 Get the display name for the configured (or system default)
620 timezone.
622 This calls :meth:`get_timezone()` and then uses some
623 heuristics to determine the name.
625 :param key: Config key for desired timezone.
627 :returns: String name for the timezone.
628 """
629 tz = self.get_timezone(key=key)
630 try:
631 # TODO: this should work for zoneinfo.ZoneInfo objects,
632 # but not sure yet about dateutils.tz ?
633 return tz.key
634 except AttributeError:
635 # this should work for system default fallback, afaik
636 dt = datetime.datetime.now(tz)
637 return dt.tzname()
639 def localtime(self, dt=None, local_zone=None, **kw):
640 """
641 This produces a datetime in the "local" timezone.
643 This is a convenience wrapper around
644 :func:`~wuttjamaican.util.localtime()`; however it also calls
645 :meth:`get_timezone()` to override the ``local_zone`` param
646 (unless caller specifies that).
648 For usage examples see :ref:`convert-to-localtime`.
650 See also :meth:`make_utc()` which is sort of the inverse; and
651 :meth:`today()`.
652 """
653 kw["local_zone"] = local_zone or self.get_timezone()
654 return localtime(dt=dt, **kw)
656 def make_utc(self, dt=None, tzinfo=False):
657 """
658 This returns a datetime local to the UTC timezone. It is a
659 convenience wrapper around
660 :func:`~wuttjamaican.util.make_utc()`.
662 For usage examples see :ref:`convert-to-utc`.
664 See also :meth:`localtime()` which is sort of the inverse.
665 """
666 return make_utc(dt=dt, tzinfo=tzinfo)
668 def today(self):
669 """
670 Convenience method to return the current date, according
671 to local time zone.
673 See also :meth:`localtime()`.
675 :returns: :class:`python:datetime.date` instance
676 """
677 return self.localtime().date()
679 # TODO: deprecate / remove this eventually
680 def make_true_uuid(self):
681 """
682 Generate a new :term:`UUID <uuid>`.
684 This is a convenience around
685 :func:`~wuttjamaican.util.make_true_uuid()`.
687 See also :meth:`make_uuid()`.
689 :returns: :class:`python:uuid.UUID` instance
690 """
691 return make_true_uuid()
693 # TODO: deprecate / remove this eventually
694 def make_str_uuid(self):
695 """
696 Generate a new :term:`UUID <uuid>` string.
698 This is a convenience around
699 :func:`~wuttjamaican.util.make_str_uuid()`.
701 See also :meth:`make_uuid()`.
703 :returns: UUID value as 32-character string.
704 """
705 return make_str_uuid()
707 # TODO: eventually refactor, to return true uuid
708 def make_uuid(self):
709 """
710 Generate a new :term:`UUID <uuid>` (for now, as string).
712 This is a convenience around
713 :func:`~wuttjamaican.util.make_uuid()`.
715 :returns: UUID as 32-character hex string
717 .. warning::
719 **TEMPORARY BEHAVIOR**
721 For the moment, use of this method is discouraged. Instead
722 you should use :meth:`make_true_uuid()` or
723 :meth:`make_str_uuid()` to be explicit about the return
724 type you expect.
726 *Eventually* (once it's clear most/all callers are using
727 the explicit methods) this will be refactored to return a
728 UUID instance. But for now this method returns a string.
729 """
730 warnings.warn(
731 "app.make_uuid() is temporarily deprecated, in favor of "
732 "explicit methods, app.make_true_uuid() and app.make_str_uuid()",
733 DeprecationWarning,
734 stacklevel=2,
735 )
736 return make_uuid()
738 def progress_loop(self, *args, **kwargs):
739 """
740 Convenience method to iterate over a set of items, invoking
741 logic for each, and updating a progress indicator along the
742 way.
744 This is a wrapper around
745 :func:`wuttjamaican.util.progress_loop()`; see those docs for
746 param details.
747 """
748 return progress_loop(*args, **kwargs)
750 def get_session(self, obj):
751 """
752 Returns the SQLAlchemy session with which the given object is
753 associated. Simple convenience wrapper around
754 :func:`sqlalchemy:sqlalchemy.orm.object_session()`.
755 """
756 from sqlalchemy import orm # pylint: disable=import-outside-toplevel
758 return orm.object_session(obj)
760 def short_session(self, **kwargs):
761 """
762 Returns a context manager for a short-lived database session.
764 This is a convenience wrapper around
765 :class:`~wuttjamaican.db.sess.short_session`.
767 If caller does not specify ``factory`` nor ``config`` params,
768 this method will provide a default factory in the form of
769 :meth:`make_session`.
770 """
771 from .db import short_session # pylint: disable=import-outside-toplevel
773 if "factory" not in kwargs and "config" not in kwargs:
774 kwargs["factory"] = self.make_session
776 return short_session(**kwargs)
778 def get_setting(self, session, name, **kwargs): # pylint: disable=unused-argument
779 """
780 Get a :term:`config setting` value from the DB.
782 This does *not* consult the :term:`config object` directly to
783 determine the setting value; it always queries the DB.
785 Default implementation is just a convenience wrapper around
786 :func:`~wuttjamaican.db.conf.get_setting()`.
788 See also :meth:`save_setting()` and :meth:`delete_setting()`.
790 :param session: App DB session.
792 :param name: Name of the setting to get.
794 :param \\**kwargs: Any remaining kwargs are ignored by the
795 default logic, but subclass may override.
797 :returns: Setting value as string, or ``None``.
798 """
799 from .db import get_setting # pylint: disable=import-outside-toplevel
801 return get_setting(session, name)
803 def save_setting(
804 self, session, name, value, force_create=False, **kwargs
805 ): # pylint: disable=unused-argument
806 """
807 Save a :term:`config setting` value to the DB.
809 See also :meth:`get_setting()` and :meth:`delete_setting()`.
811 :param session: Current :term:`db session`.
813 :param name: Name of the setting to save.
815 :param value: Value to be saved for the setting; should be
816 either a string or ``None``.
818 :param force_create: If ``False`` (the default) then logic
819 will first try to locate an existing setting of the same
820 name, and update it if found, or create if not.
822 But if this param is ``True`` then logic will only try to
823 create a new record, and not bother checking to see if it
824 exists.
826 (Theoretically the latter offers a slight efficiency gain.)
828 :param \\**kwargs: Any remaining kwargs are ignored by the
829 default logic, but subclass may override.
830 """
831 model = self.model
833 # maybe fetch existing setting
834 setting = None
835 if not force_create:
836 setting = session.get(model.Setting, name)
838 # create setting if needed
839 if not setting:
840 setting = model.Setting(name=name)
841 session.add(setting)
843 # set value
844 setting.value = value
846 def delete_setting(
847 self, session, name, **kwargs
848 ): # pylint: disable=unused-argument
849 """
850 Delete a :term:`config setting` from the DB.
852 See also :meth:`get_setting()` and :meth:`save_setting()`.
854 :param session: Current :term:`db session`.
856 :param name: Name of the setting to delete.
858 :param \\**kwargs: Any remaining kwargs are ignored by the
859 default logic, but subclass may override.
860 """
861 model = self.model
862 setting = session.get(model.Setting, name)
863 if setting:
864 session.delete(setting)
866 def continuum_is_enabled(self):
867 """
868 Returns boolean indicating if Wutta-Continuum is installed and
869 enabled.
871 Default will be ``False`` as enabling it requires additional
872 installation and setup. For instructions see
873 :doc:`wutta-continuum:narr/install`.
874 """
875 for provider in self.providers.values():
876 if hasattr(provider, "continuum_is_enabled"):
877 return provider.continuum_is_enabled()
879 return False
881 ##############################
882 # common value renderers
883 ##############################
885 def render_boolean(self, value):
886 """
887 Render a boolean value for display.
889 :param value: A boolean, or ``None``.
891 :returns: Display string for the value.
892 """
893 if value is None:
894 return ""
896 return "Yes" if value else "No"
898 def render_currency(self, value, scale=2):
899 """
900 Return a human-friendly display string for the given currency
901 value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
903 :param value: Either a :class:`python:decimal.Decimal` or
904 :class:`python:float` value.
906 :param scale: Number of decimal digits to be displayed.
908 :returns: Display string for the value.
909 """
910 if value is None:
911 return ""
913 if value < 0:
914 fmt = f"(${{:0,.{scale}f}})"
915 return fmt.format(0 - value)
917 fmt = f"${{:0,.{scale}f}}"
918 return fmt.format(value)
920 display_format_date = "%Y-%m-%d"
921 """
922 Format string to use when displaying :class:`python:datetime.date`
923 objects. See also :meth:`render_date()`.
924 """
926 display_format_datetime = "%Y-%m-%d %H:%M%z"
927 """
928 Format string to use when displaying
929 :class:`python:datetime.datetime` objects. See also
930 :meth:`render_datetime()`.
931 """
933 def render_date(self, value, local=True):
934 """
935 Return a human-friendly display string for the given date.
937 Uses :attr:`display_format_date` to render the value.
939 :param value: Can be a :class:`python:datetime.date` *or*
940 :class:`python:datetime.datetime` instance, or ``None``.
942 :param local: By default the ``value`` will first be passed to
943 :meth:`localtime()` to normalize it for display (but only
944 if value is a ``datetime`` instance). Specify
945 ``local=False`` to skip that and render the value as-is.
947 :returns: Display string.
948 """
949 if value is None:
950 return ""
952 if local and isinstance(value, datetime.datetime):
953 value = self.localtime(value)
955 return value.strftime(self.display_format_date)
957 def render_datetime(self, value, local=True, html=False):
958 """
959 Return a human-friendly display string for the given datetime.
961 Uses :attr:`display_format_datetime` to render the value.
963 :param value: A :class:`python:datetime.datetime` instance (or
964 ``None``).
966 :param local: By default the ``value`` will first be passed to
967 :meth:`localtime()` to normalize it for display. Specify
968 ``local=False`` to skip that and render the value as-is.
970 :param html: If true, return HTML (with tooltip showing
971 relative time delta) instead of plain text.
973 :returns: Rendered datetime as string (or HTML with tooltip).
974 """
975 if value is None:
976 return ""
978 # we usually want to render a "local" time
979 if local:
980 value = self.localtime(value)
982 # simple formatted text
983 text = value.strftime(self.display_format_datetime)
985 if html:
987 # calculate time diff
988 # nb. if both times are naive, they should be UTC;
989 # otherwise if both are zone-aware, this should work even
990 # if they use different zones.
991 delta = self.make_utc(tzinfo=bool(value.tzinfo)) - value
993 # show text w/ time diff as tooltip
994 return HTML.tag("span", c=text, title=self.render_time_ago(delta))
996 return text
998 def render_error(self, error):
999 """
1000 Return a "human-friendly" display string for the error, e.g.
1001 when showing it to the user.
1003 By default, this is a convenience wrapper for
1004 :func:`~wuttjamaican.util.simple_error()`.
1005 """
1006 return simple_error(error)
1008 def render_percent(self, value, decimals=2):
1009 """
1010 Return a human-friendly display string for the given
1011 percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
1013 :param value: The value to be rendered.
1015 :returns: Display string for the percentage value.
1016 """
1017 if value is None:
1018 return ""
1019 fmt = f"{{:0.{decimals}f}} %"
1020 if value < 0:
1021 return f"({fmt.format(-value)})"
1022 return fmt.format(value)
1024 def render_quantity(self, value, empty_zero=False):
1025 """
1026 Return a human-friendly display string for the given quantity
1027 value, e.g. ``1.000`` becomes ``"1"``.
1029 :param value: The quantity to be rendered.
1031 :param empty_zero: Affects the display when value equals zero.
1032 If false (the default), will return ``'0'``; if true then
1033 it returns empty string.
1035 :returns: Display string for the quantity.
1036 """
1037 if value is None:
1038 return ""
1039 if int(value) == value:
1040 value = int(value)
1041 if empty_zero and value == 0:
1042 return ""
1043 return f"{value:,}"
1044 return f"{value:,}".rstrip("0")
1046 def render_time_ago(self, value):
1047 """
1048 Return a human-friendly string, indicating how long ago
1049 something occurred.
1051 Default logic uses :func:`humanize:humanize.naturaltime()` for
1052 the rendering.
1054 :param value: Instance of :class:`python:datetime.datetime` or
1055 :class:`python:datetime.timedelta`.
1057 :returns: Text to display.
1058 """
1059 # TODO: this now assumes naive UTC value incoming...
1060 return humanize.naturaltime(value, when=self.make_utc(tzinfo=False))
1062 ##############################
1063 # getters for other handlers
1064 ##############################
1066 def get_auth_handler(self, **kwargs):
1067 """
1068 Get the configured :term:`auth handler`.
1070 :rtype: :class:`~wuttjamaican.auth.AuthHandler`
1071 """
1072 if "auth" not in self.handlers:
1073 spec = self.config.get(
1074 f"{self.appname}.auth.handler", default=self.default_auth_handler_spec
1075 )
1076 factory = self.load_object(spec)
1077 self.handlers["auth"] = factory(self.config, **kwargs)
1078 return self.handlers["auth"]
1080 def get_batch_handler(self, key, default=None, **kwargs):
1081 """
1082 Get the configured :term:`batch handler` for the given type.
1084 :param key: Unique key designating the :term:`batch type`.
1086 :param default: Spec string to use as the default, if none is
1087 configured.
1089 :returns: :class:`~wuttjamaican.batch.BatchHandler` instance
1090 for the requested type. If no spec can be determined, a
1091 ``KeyError`` is raised.
1092 """
1093 spec = self.config.get(
1094 f"{self.appname}.batch.{key}.handler.spec", default=default
1095 )
1096 if not spec:
1097 spec = self.config.get(f"{self.appname}.batch.{key}.handler.default_spec")
1098 if not spec:
1099 raise KeyError(f"handler spec not found for batch key: {key}")
1100 factory = self.load_object(spec)
1101 return factory(self.config, **kwargs)
1103 def get_batch_handler_specs(self, key, default=None):
1104 """
1105 Get the :term:`spec` strings for all available handlers of the
1106 given batch type.
1108 :param key: Unique key designating the :term:`batch type`.
1110 :param default: Default spec string(s) to include, even if not
1111 registered. Can be a string or list of strings.
1113 :returns: List of batch handler spec strings.
1115 This will gather available spec strings from the following:
1117 First, the ``default`` as provided by caller.
1119 Second, the default spec from config, if set; for example:
1121 .. code-block:: ini
1123 [wutta.batch]
1124 inventory.handler.default_spec = poser.batch.inventory:InventoryBatchHandler
1126 Third, each spec registered via entry points. For instance in
1127 ``pyproject.toml``:
1129 .. code-block:: toml
1131 [project.entry-points."wutta.batch.inventory"]
1132 poser = "poser.batch.inventory:InventoryBatchHandler"
1134 The final list will be "sorted" according to the above, with
1135 the latter registered handlers being sorted alphabetically.
1136 """
1137 handlers = []
1139 # defaults from caller
1140 if isinstance(default, str):
1141 handlers.append(default)
1142 elif default:
1143 handlers.extend(default)
1145 # configured default, if applicable
1146 default = self.config.get(
1147 f"{self.config.appname}.batch.{key}.handler.default_spec"
1148 )
1149 if default and default not in handlers:
1150 handlers.append(default)
1152 # registered via entry points
1153 registered = []
1154 for handler in load_entry_points(f"{self.appname}.batch.{key}").values():
1155 spec = handler.get_spec()
1156 if spec not in handlers:
1157 registered.append(spec)
1158 if registered:
1159 registered.sort()
1160 handlers.extend(registered)
1162 return handlers
1164 def get_db_handler(self, **kwargs):
1165 """
1166 Get the configured :term:`db handler`.
1168 :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler`
1169 """
1170 if "db" not in self.handlers:
1171 spec = self.config.get(
1172 f"{self.appname}.db.handler", default=self.default_db_handler_spec
1173 )
1174 factory = self.load_object(spec)
1175 self.handlers["db"] = factory(self.config, **kwargs)
1176 return self.handlers["db"]
1178 def get_email_handler(self, **kwargs):
1179 """
1180 Get the configured :term:`email handler`.
1182 See also :meth:`send_email()`.
1184 :rtype: :class:`~wuttjamaican.email.EmailHandler`
1185 """
1186 if "email" not in self.handlers:
1187 spec = self.config.get(
1188 f"{self.appname}.email.handler", default=self.default_email_handler_spec
1189 )
1190 factory = self.load_object(spec)
1191 self.handlers["email"] = factory(self.config, **kwargs)
1192 return self.handlers["email"]
1194 def get_install_handler(self, **kwargs):
1195 """
1196 Get the configured :term:`install handler`.
1198 :rtype: :class:`~wuttjamaican.install.handler.InstallHandler`
1199 """
1200 if "install" not in self.handlers:
1201 spec = self.config.get(
1202 f"{self.appname}.install.handler",
1203 default=self.default_install_handler_spec,
1204 )
1205 factory = self.load_object(spec)
1206 self.handlers["install"] = factory(self.config, **kwargs)
1207 return self.handlers["install"]
1209 def get_people_handler(self, **kwargs):
1210 """
1211 Get the configured "people" :term:`handler`.
1213 :rtype: :class:`~wuttjamaican.people.PeopleHandler`
1214 """
1215 if "people" not in self.handlers:
1216 spec = self.config.get(
1217 f"{self.appname}.people.handler",
1218 default=self.default_people_handler_spec,
1219 )
1220 factory = self.load_object(spec)
1221 self.handlers["people"] = factory(self.config, **kwargs)
1222 return self.handlers["people"]
1224 def get_problem_handler(self, **kwargs):
1225 """
1226 Get the configured :term:`problem handler`.
1228 :rtype: :class:`~wuttjamaican.problems.ProblemHandler`
1229 """
1230 if "problems" not in self.handlers:
1231 spec = self.config.get(
1232 f"{self.appname}.problems.handler",
1233 default=self.default_problem_handler_spec,
1234 )
1235 log.debug("problem_handler spec is: %s", spec)
1236 factory = self.load_object(spec)
1237 self.handlers["problems"] = factory(self.config, **kwargs)
1238 return self.handlers["problems"]
1240 def get_report_handler(self, **kwargs):
1241 """
1242 Get the configured :term:`report handler`.
1244 :rtype: :class:`~wuttjamaican.reports.ReportHandler`
1245 """
1246 if "reports" not in self.handlers:
1247 spec = self.config.get(
1248 f"{self.appname}.reports.handler_spec",
1249 default=self.default_report_handler_spec,
1250 )
1251 factory = self.load_object(spec)
1252 self.handlers["reports"] = factory(self.config, **kwargs)
1253 return self.handlers["reports"]
1255 ##############################
1256 # convenience delegators
1257 ##############################
1259 def get_person(self, obj, **kwargs):
1260 """
1261 Convenience method to locate a
1262 :class:`~wuttjamaican.db.model.base.Person` for the given
1263 object.
1265 This delegates to the "people" handler method,
1266 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
1267 """
1268 return self.get_people_handler().get_person(obj, **kwargs)
1270 def send_email(self, *args, **kwargs):
1271 """
1272 Send an email message.
1274 This is a convenience wrapper around
1275 :meth:`~wuttjamaican.email.EmailHandler.send_email()`.
1276 """
1277 self.get_email_handler().send_email(*args, **kwargs)
1280class AppProvider: # pylint: disable=too-few-public-methods
1281 """
1282 Base class for :term:`app providers<app provider>`.
1284 These can add arbitrary extra functionality to the main :term:`app
1285 handler`. See also :doc:`/narr/providers/app`.
1287 :param config: The app :term:`config object`.
1289 ``AppProvider`` instances have the following attributes:
1291 .. attribute:: config
1293 Reference to the config object.
1295 .. attribute:: app
1297 Reference to the parent app handler.
1299 Some things which a subclass may define, in order to register
1300 various features with the app:
1302 .. attribute:: email_modules
1304 List of :term:`email modules <email module>` provided. Should
1305 be a list of strings; each is a dotted module path, e.g.::
1307 email_modules = ['poser.emails']
1309 .. attribute:: email_templates
1311 List of :term:`email template` folders provided. Can be a list
1312 of paths, or a single path as string::
1314 email_templates = ['poser:templates/email']
1316 email_templates = 'poser:templates/email'
1318 Note the syntax, which specifies python module, then colon
1319 (``:``), then filesystem path below that. However absolute
1320 file paths may be used as well, when applicable.
1321 """
1323 def __init__(self, config):
1324 if isinstance(config, AppHandler):
1325 warnings.warn(
1326 "passing app handler to app provider is deprecated; "
1327 "must pass config object instead",
1328 DeprecationWarning,
1329 stacklevel=2,
1330 )
1331 config = config.config
1333 self.config = config
1334 self.app = self.config.get_app()
1336 @property
1337 def appname(self):
1338 """
1339 The :term:`app name` for the current app.
1341 See also :attr:`AppHandler.appname`.
1342 """
1343 return self.app.appname
1346class GenericHandler:
1347 """
1348 Generic base class for handlers.
1350 When the :term:`app` defines a new *type* of :term:`handler` it
1351 may subclass this when defining the handler base class.
1353 :param config: Config object for the app. This should be an
1354 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
1355 """
1357 def __init__(self, config):
1358 self.config = config
1359 self.app = self.config.get_app()
1360 self.modules = {}
1361 self.classes = {}
1363 @property
1364 def appname(self):
1365 """
1366 The :term:`app name` for the current app.
1368 See also :attr:`AppHandler.appname`.
1369 """
1370 return self.app.appname
1372 @classmethod
1373 def get_spec(cls):
1374 """
1375 Returns the class :term:`spec` string for the handler.
1376 """
1377 return f"{cls.__module__}:{cls.__name__}"
1379 def get_provider_modules(self, module_type):
1380 """
1381 Returns a list of all available modules of the given type.
1383 Not all handlers would need such a thing, but notable ones
1384 which do are the :term:`email handler` and :term:`report
1385 handler`. Both can obtain classes (emails or reports) from
1386 arbitrary modules, and this method is used to locate them.
1388 This will discover all modules exposed by the app
1389 :term:`providers <provider>`, which expose an attribute with
1390 name like ``f"{module_type}_modules"``.
1392 :param module_type: Unique name referring to a particular
1393 "type" of modules to locate, e.g. ``'email'``.
1395 :returns: List of module objects.
1396 """
1397 if module_type not in self.modules:
1398 self.modules[module_type] = []
1399 for provider in self.app.providers.values():
1400 name = f"{module_type}_modules"
1401 if hasattr(provider, name):
1402 modules = getattr(provider, name)
1403 if modules:
1404 if isinstance(modules, str):
1405 modules = [modules]
1406 for modpath in modules:
1407 module = importlib.import_module(modpath)
1408 self.modules[module_type].append(module)
1409 return self.modules[module_type]