Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/app.py: 100%
374 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-07 21:12 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-07 21:12 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2026 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"""
27# pylint: disable=too-many-lines
29import datetime
30import logging
31import os
32import sys
33import warnings
34import importlib
35from importlib.metadata import version
37import humanize
38from webhelpers2.html import HTML
40from wuttjamaican.util import (
41 get_timezone_by_name,
42 get_value,
43 localtime,
44 load_entry_points,
45 load_object,
46 make_title,
47 make_full_name,
48 make_utc,
49 make_uuid,
50 make_str_uuid,
51 make_true_uuid,
52 progress_loop,
53 resource_path,
54 simple_error,
55)
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_cleanup_handler_spec = "wuttjamaican.cleanup:CleanupHandler"
109 default_db_handler_spec = "wuttjamaican.db.handler:DatabaseHandler"
110 default_email_handler_spec = "wuttjamaican.email:EmailHandler"
111 default_install_handler_spec = "wuttjamaican.install:InstallHandler"
112 default_people_handler_spec = "wuttjamaican.people:PeopleHandler"
113 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler"
114 default_report_handler_spec = "wuttjamaican.reports:ReportHandler"
115 default_upgrade_handler_spec = "wuttjamaican.upgrades:UpgradeHandler"
117 def __init__(self, config):
118 self.config = config
119 self.handlers = {}
120 self.timezones = {}
122 @property
123 def appname(self):
124 """
125 The :term:`app name` for the current app. This is just an
126 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`.
128 Note that this ``appname`` does not necessariy reflect what
129 you think of as the name of your (e.g. custom) app. It is
130 more fundamental than that; your Python package naming and the
131 :term:`app title` are free to use a different name as their
132 basis.
133 """
134 return self.config.appname
136 def __getattr__(self, name):
137 """
138 Custom attribute getter, called when the app handler does not
139 already have an attribute with the given ``name``.
141 This will delegate to the set of :term:`app providers<app
142 provider>`; the first provider with an appropriately-named
143 attribute wins, and that value is returned.
145 :returns: The first value found among the set of app
146 providers.
147 """
149 if name == "model":
150 return self.get_model()
152 if name == "enum":
153 return self.get_enum()
155 if name == "providers":
156 self.__dict__["providers"] = self.get_all_providers()
157 return self.providers
159 for provider in self.providers.values():
160 if hasattr(provider, name):
161 return getattr(provider, name)
163 raise AttributeError(f"attr not found: {name}")
165 def get_all_providers(self):
166 """
167 Load and return all registered providers.
169 Note that you do not need to call this directly; instead just
170 use :attr:`providers`.
172 The discovery logic is based on :term:`entry points<entry
173 point>` using the ``wutta.app.providers`` group. For instance
174 here is a sample entry point used by WuttaWeb (in its
175 ``pyproject.toml``):
177 .. code-block:: toml
179 [project.entry-points."wutta.app.providers"]
180 wuttaweb = "wuttaweb.app:WebAppProvider"
182 :returns: Dictionary keyed by entry point name; values are
183 :class:`AppProvider` instances.
184 """
185 # nb. must use 'wutta' and not self.appname prefix here, or
186 # else we can't find all providers with custom appname
187 providers = load_entry_points("wutta.app.providers")
188 for key in list(providers):
189 providers[key] = providers[key](self.config)
190 return providers
192 def get_title(self, default=None):
193 """
194 Returns the configured title for the app.
196 :param default: Value to be returned if there is no app title
197 configured.
199 :returns: Title for the app.
200 """
201 return self.config.get(
202 f"{self.appname}.app_title", default=default or self.default_app_title
203 )
205 def get_node_title(self, default=None):
206 """
207 Returns the configured title for the local app node.
209 If none is configured, and no default provided, will return
210 the value from :meth:`get_title()`.
212 :param default: Value to use if the node title is not
213 configured.
215 :returns: Title for the local app node.
216 """
217 title = self.config.get(f"{self.appname}.node_title")
218 if title:
219 return title
220 return self.get_title(default=default)
222 def get_node_type(self, default=None):
223 """
224 Returns the "type" of current app node.
226 The framework itself does not (yet?) have any notion of what a
227 node type means. This abstraction is here for convenience, in
228 case it is needed by a particular app ecosystem.
230 :returns: String name for the node type, or ``None``.
232 The node type must be configured via file; this cannot be done
233 with a DB setting. Depending on :attr:`appname` that is like
234 so:
236 .. code-block:: ini
238 [wutta]
239 node_type = warehouse
240 """
241 return self.config.get(
242 f"{self.appname}.node_type", default=default, usedb=False
243 )
245 def get_distribution(self, obj=None):
246 """
247 Returns the appropriate Python distribution name.
249 If ``obj`` is specified, this will attempt to locate the
250 distribution based on the top-level module which contains the
251 object's type/class.
253 If ``obj`` is *not* specified, this behaves a bit differently.
254 It first will look for a :term:`config setting` named
255 ``wutta.app_dist`` (or similar, depending on :attr:`appname`).
256 If there is such a config value, it is returned. Otherwise
257 the "auto-locate" logic described above happens, but using
258 ``self`` instead of ``obj``.
260 In other words by default this returns the distribution to
261 which the running :term:`app handler` belongs.
263 See also :meth:`get_version()`.
265 :param obj: Any object which may be used as a clue to locate
266 the appropriate distribution.
268 :returns: string, or ``None``
270 Also note that a *distribution* name is different from a
271 *package* name. The distribution name is how things appear on
272 PyPI for instance.
274 If you want to override the default distribution name (and
275 skip the auto-locate based on app handler) then you can define
276 it in config:
278 .. code-block:: ini
280 [wutta]
281 app_dist = My-Poser-Dist
282 """
283 if obj is None:
284 dist = self.config.get(f"{self.appname}.app_dist")
285 if dist:
286 return dist
288 # TODO: do we need a config setting for app_package ?
289 # modpath = self.config.get(f'{self.appname}.app_package')
290 modpath = None
291 if not modpath:
292 modpath = type(obj if obj is not None else self).__module__
293 pkgname = modpath.split(".")[0]
295 try:
296 from importlib.metadata import ( # pylint: disable=import-outside-toplevel
297 packages_distributions,
298 )
299 except ImportError: # python < 3.10
300 from importlib_metadata import ( # pylint: disable=import-outside-toplevel
301 packages_distributions,
302 )
304 pkgmap = packages_distributions()
305 if pkgname in pkgmap:
306 dist = pkgmap[pkgname][0]
307 return dist
309 # fall back to configured dist, if obj lookup failed
310 return self.config.get(f"{self.appname}.app_dist")
312 def get_version(self, dist=None, obj=None):
313 """
314 Returns the version of a given Python distribution.
316 If ``dist`` is not specified, calls :meth:`get_distribution()`
317 to get it. (It passes ``obj`` along for this).
319 So by default this will return the version of whichever
320 distribution owns the running :term:`app handler`.
322 :returns: Version as string.
323 """
324 if not dist:
325 dist = self.get_distribution(obj=obj)
326 if dist:
327 return version(dist)
328 return None
330 def get_model(self):
331 """
332 Returns the :term:`app model` module.
334 Note that you don't actually need to call this method; you can
335 get the model by simply accessing :attr:`model`
336 (e.g. ``app.model``) instead.
338 By default this will return :mod:`wuttjamaican.db.model`
339 unless the config class or some :term:`config extension` has
340 provided another default.
342 A custom app can override the default like so (within a config
343 extension)::
345 config.setdefault('wutta.model_spec', 'poser.db.model')
346 """
347 if "model" not in self.__dict__:
348 spec = self.config.get(
349 f"{self.appname}.model_spec",
350 usedb=False,
351 default=self.default_model_spec,
352 )
353 self.__dict__["model"] = importlib.import_module(spec)
354 return self.model
356 def get_enum(self):
357 """
358 Returns the :term:`app enum` module.
360 Note that you don't actually need to call this method; you can
361 get the module by simply accessing :attr:`enum`
362 (e.g. ``app.enum``) instead.
364 By default this will return :mod:`wuttjamaican.enum` unless
365 the config class or some :term:`config extension` has provided
366 another default.
368 A custom app can override the default like so (within a config
369 extension)::
371 config.setdefault('wutta.enum_spec', 'poser.enum')
372 """
373 if "enum" not in self.__dict__:
374 spec = self.config.get(
375 f"{self.appname}.enum_spec", usedb=False, default=self.default_enum_spec
376 )
377 self.__dict__["enum"] = importlib.import_module(spec)
378 return self.enum
380 def get_value(self, obj, key):
381 """
382 Convenience wrapper around
383 :func:`wuttjamaican.util.get_value()`.
385 :param obj: Arbitrary dict or object of any kind which would
386 have named attributes.
388 :param key: Key/name of the field to get.
390 :returns: Whatever value is found. Or maybe an
391 ``AttributeError`` is raised if the object does not have
392 the key/attr set.
393 """
394 return get_value(obj, key)
396 def load_object(self, spec):
397 """
398 Import and/or load and return the object designated by the
399 given spec string.
401 This invokes :func:`wuttjamaican.util.load_object()`.
403 :param spec: String of the form ``module.dotted.path:objname``.
405 :returns: The object referred to by ``spec``. If the module
406 could not be imported, or did not contain an object of the
407 given name, then an error will raise.
408 """
409 return load_object(spec)
411 def get_appdir(self, *args, **kwargs):
412 """
413 Returns path to the :term:`app dir`.
415 This does not check for existence of the path, it only reads
416 it from config or (optionally) provides a default path.
418 :param configured_only: Pass ``True`` here if you only want
419 the configured path and ignore the default path.
421 :param create: Pass ``True`` here if you want to ensure the
422 returned path exists, creating it if necessary.
424 :param \\*args: Any additional args will be added as child
425 paths for the final value.
427 For instance, assuming ``/srv/envs/poser`` is the virtual
428 environment root::
430 app.get_appdir() # => /srv/envs/poser/app
432 app.get_appdir('data') # => /srv/envs/poser/app/data
433 """
434 configured_only = kwargs.pop("configured_only", False)
435 create = kwargs.pop("create", False)
437 # maybe specify default path
438 if not configured_only:
439 path = os.path.join(sys.prefix, "app")
440 kwargs.setdefault("default", path)
442 # get configured path
443 kwargs.setdefault("usedb", False)
444 path = self.config.get(f"{self.appname}.appdir", **kwargs)
446 # add any subpath info
447 if path and args:
448 path = os.path.join(path, *args)
450 # create path if requested/needed
451 if create:
452 if not path:
453 raise ValueError("appdir path unknown! so cannot create it.")
454 if not os.path.exists(path):
455 os.makedirs(path)
457 return path
459 def make_appdir(self, path, subfolders=None):
460 """
461 Establish an :term:`app dir` at the given path.
463 Default logic only creates a few subfolders, meant to help
464 steer the admin toward a convention for sake of where to put
465 things. But custom app handlers are free to do whatever.
467 :param path: Path to the desired app dir. If the path does
468 not yet exist then it will be created. But regardless it
469 should be "refreshed" (e.g. missing subfolders created)
470 when this method is called.
472 :param subfolders: Optional list of subfolder names to create
473 within the app dir. If not specified, defaults will be:
474 ``['cache', 'data', 'log', 'work']``.
475 """
476 appdir = path
477 if not os.path.exists(appdir):
478 os.makedirs(appdir)
480 if not subfolders:
481 subfolders = ["cache", "data", "log", "work"]
483 for name in subfolders:
484 path = os.path.join(appdir, name)
485 if not os.path.exists(path):
486 os.mkdir(path)
488 def render_mako_template(
489 self,
490 template,
491 context,
492 output_path=None,
493 ):
494 """
495 Convenience method to render a Mako template.
497 :param template: :class:`~mako:mako.template.Template`
498 instance.
500 :param context: Dict of context for the template.
502 :param output_path: Optional path to which output should be
503 written.
505 :returns: Rendered output as string.
506 """
507 output = template.render(**context)
508 if output_path:
509 with open(output_path, "wt", encoding="utf_8") as f:
510 f.write(output)
511 return output
513 def resource_path(self, path):
514 """
515 Convenience wrapper for
516 :func:`wuttjamaican.util.resource_path()`.
517 """
518 return resource_path(path)
520 def make_session(self, **kwargs):
521 """
522 Creates a new SQLAlchemy session for the app DB. By default
523 this will create a new :class:`~wuttjamaican.db.sess.Session`
524 instance.
526 :returns: SQLAlchemy session for the app DB.
527 """
528 from .db import Session # pylint: disable=import-outside-toplevel
530 return Session(**kwargs)
532 def make_title(self, text):
533 """
534 Return a human-friendly "title" for the given text.
536 This is mostly useful for converting a Python variable name (or
537 similar) to a human-friendly string, e.g.::
539 make_title('foo_bar') # => 'Foo Bar'
541 By default this just invokes
542 :func:`wuttjamaican.util.make_title()`.
543 """
544 return make_title(text)
546 def make_full_name(self, *parts):
547 """
548 Make a "full name" from the given parts.
550 This is a convenience wrapper around
551 :func:`~wuttjamaican.util.make_full_name()`.
552 """
553 return make_full_name(*parts)
555 def get_timezone(self, key="default"):
556 """
557 Get the configured (or system default) timezone object.
559 This checks config for a setting which corresponds to the
560 given ``key``, then calls
561 :func:`~wuttjamaican.util.get_timezone_by_name()` to get the
562 actual timezone object.
564 The default key corresponds to the true "local" timezone, but
565 other keys may correspond to other configured timezones (if
566 applicable).
568 As a special case for the default key only: If no config value
569 is found, Python itself will determine the default system
570 local timezone.
572 For any non-default key, an error is raised if no config value
573 is found.
575 .. note::
577 The app handler *caches* all timezone objects, to avoid
578 unwanted repetitive lookups when processing multiple
579 datetimes etc. (Since this method is called by
580 :meth:`localtime()`.) Therefore whenever timezone config
581 values are changed, an app restart will be necessary.
583 Example config:
585 .. code-block:: ini
587 [wutta]
588 timezone.default = America/Chicago
589 timezone.westcoast = America/Los_Angeles
591 Example usage::
593 tz_default = app.get_timezone()
594 tz_westcoast = app.get_timezone("westcoast")
596 See also :meth:`get_timezone_name()`.
598 :param key: Config key for desired timezone.
600 :returns: :class:`python:datetime.tzinfo` instance
601 """
602 if key not in self.timezones:
603 setting = f"{self.appname}.timezone.{key}"
604 tzname = self.config.get(setting)
605 if tzname:
606 self.timezones[key] = get_timezone_by_name(tzname)
608 elif key == "default":
609 # fallback to system default
610 self.timezones[key] = datetime.datetime.now().astimezone().tzinfo
612 else:
613 # alternate key was specified, but no config found, so check
614 # again with require() to force error
615 self.timezones[key] = self.config.require(setting)
617 return self.timezones[key]
619 def get_timezone_name(self, key="default"):
620 """
621 Get the display name for the configured (or system default)
622 timezone.
624 This calls :meth:`get_timezone()` and then uses some
625 heuristics to determine the name.
627 :param key: Config key for desired timezone.
629 :returns: String name for the timezone.
630 """
631 tz = self.get_timezone(key=key)
632 try:
633 # TODO: this should work for zoneinfo.ZoneInfo objects,
634 # but not sure yet about dateutils.tz ?
635 return tz.key
636 except AttributeError:
637 # this should work for system default fallback, afaik
638 dt = datetime.datetime.now(tz)
639 return dt.tzname()
641 def localtime(self, dt=None, local_zone=None, **kw):
642 """
643 This produces a datetime in the "local" timezone.
645 This is a convenience wrapper around
646 :func:`~wuttjamaican.util.localtime()`; however it also calls
647 :meth:`get_timezone()` to override the ``local_zone`` param
648 (unless caller specifies that).
650 For usage examples see :ref:`convert-to-localtime`.
652 See also :meth:`make_utc()` which is sort of the inverse; and
653 :meth:`today()`.
654 """
655 kw["local_zone"] = local_zone or self.get_timezone()
656 return localtime(dt=dt, **kw)
658 def make_utc(self, dt=None, tzinfo=False):
659 """
660 This returns a datetime local to the UTC timezone. It is a
661 convenience wrapper around
662 :func:`~wuttjamaican.util.make_utc()`.
664 For usage examples see :ref:`convert-to-utc`.
666 See also :meth:`localtime()` which is sort of the inverse.
667 """
668 return make_utc(dt=dt, tzinfo=tzinfo)
670 def today(self):
671 """
672 Convenience method to return the current date, according
673 to local time zone.
675 See also :meth:`localtime()`.
677 :returns: :class:`python:datetime.date` instance
678 """
679 return self.localtime().date()
681 # TODO: deprecate / remove this eventually
682 def make_true_uuid(self):
683 """
684 Generate a new :term:`UUID <uuid>`.
686 This is a convenience around
687 :func:`~wuttjamaican.util.make_true_uuid()`.
689 See also :meth:`make_uuid()`.
691 :returns: :class:`python:uuid.UUID` instance
692 """
693 return make_true_uuid()
695 # TODO: deprecate / remove this eventually
696 def make_str_uuid(self):
697 """
698 Generate a new :term:`UUID <uuid>` string.
700 This is a convenience around
701 :func:`~wuttjamaican.util.make_str_uuid()`.
703 See also :meth:`make_uuid()`.
705 :returns: UUID value as 32-character string.
706 """
707 return make_str_uuid()
709 # TODO: eventually refactor, to return true uuid
710 def make_uuid(self):
711 """
712 Generate a new :term:`UUID <uuid>` (for now, as string).
714 This is a convenience around
715 :func:`~wuttjamaican.util.make_uuid()`.
717 :returns: UUID as 32-character hex string
719 .. warning::
721 **TEMPORARY BEHAVIOR**
723 For the moment, use of this method is discouraged. Instead
724 you should use :meth:`make_true_uuid()` or
725 :meth:`make_str_uuid()` to be explicit about the return
726 type you expect.
728 *Eventually* (once it's clear most/all callers are using
729 the explicit methods) this will be refactored to return a
730 UUID instance. But for now this method returns a string.
731 """
732 warnings.warn(
733 "app.make_uuid() is temporarily deprecated, in favor of "
734 "explicit methods, app.make_true_uuid() and app.make_str_uuid()",
735 DeprecationWarning,
736 stacklevel=2,
737 )
738 return make_uuid()
740 def progress_loop(self, *args, **kwargs):
741 """
742 Convenience method to iterate over a set of items, invoking
743 logic for each, and updating a progress indicator along the
744 way.
746 This is a wrapper around
747 :func:`wuttjamaican.util.progress_loop()`; see those docs for
748 param details.
749 """
750 return progress_loop(*args, **kwargs)
752 def get_session(self, obj):
753 """
754 Returns the SQLAlchemy session with which the given object is
755 associated. Simple convenience wrapper around
756 :func:`sqlalchemy:sqlalchemy.orm.object_session()`.
757 """
758 from sqlalchemy import orm # pylint: disable=import-outside-toplevel
760 return orm.object_session(obj)
762 def short_session(self, **kwargs):
763 """
764 Returns a context manager for a short-lived database session.
766 This is a convenience wrapper around
767 :class:`~wuttjamaican.db.sess.short_session`.
769 If caller does not specify ``factory`` nor ``config`` params,
770 this method will provide a default factory in the form of
771 :meth:`make_session`.
772 """
773 from .db import short_session # pylint: disable=import-outside-toplevel
775 if "factory" not in kwargs and "config" not in kwargs:
776 kwargs["factory"] = self.make_session
778 return short_session(**kwargs)
780 def get_setting(self, session, name, **kwargs): # pylint: disable=unused-argument
781 """
782 Get a :term:`config setting` value from the DB.
784 This does *not* consult the :term:`config object` directly to
785 determine the setting value; it always queries the DB.
787 Default implementation is just a convenience wrapper around
788 :func:`~wuttjamaican.db.conf.get_setting()`.
790 See also :meth:`save_setting()` and :meth:`delete_setting()`.
792 :param session: App DB session.
794 :param name: Name of the setting to get.
796 :param \\**kwargs: Any remaining kwargs are ignored by the
797 default logic, but subclass may override.
799 :returns: Setting value as string, or ``None``.
800 """
801 from .db import get_setting # pylint: disable=import-outside-toplevel
803 return get_setting(session, name)
805 def save_setting(
806 self, session, name, value, force_create=False, **kwargs
807 ): # pylint: disable=unused-argument
808 """
809 Save a :term:`config setting` value to the DB.
811 See also :meth:`get_setting()` and :meth:`delete_setting()`.
813 :param session: Current :term:`db session`.
815 :param name: Name of the setting to save.
817 :param value: Value to be saved for the setting; should be
818 either a string or ``None``.
820 :param force_create: If ``False`` (the default) then logic
821 will first try to locate an existing setting of the same
822 name, and update it if found, or create if not.
824 But if this param is ``True`` then logic will only try to
825 create a new record, and not bother checking to see if it
826 exists.
828 (Theoretically the latter offers a slight efficiency gain.)
830 :param \\**kwargs: Any remaining kwargs are ignored by the
831 default logic, but subclass may override.
832 """
833 model = self.model
835 # maybe fetch existing setting
836 setting = None
837 if not force_create:
838 setting = session.get(model.Setting, name)
840 # create setting if needed
841 if not setting:
842 setting = model.Setting(name=name)
843 session.add(setting)
845 # set value
846 setting.value = value
848 def delete_setting(
849 self, session, name, **kwargs
850 ): # pylint: disable=unused-argument
851 """
852 Delete a :term:`config setting` from the DB.
854 See also :meth:`get_setting()` and :meth:`save_setting()`.
856 :param session: Current :term:`db session`.
858 :param name: Name of the setting to delete.
860 :param \\**kwargs: Any remaining kwargs are ignored by the
861 default logic, but subclass may override.
862 """
863 model = self.model
864 setting = session.get(model.Setting, name)
865 if setting:
866 session.delete(setting)
868 def continuum_is_enabled(self):
869 """
870 Returns boolean indicating if Wutta-Continuum is installed and
871 enabled.
873 Default will be ``False`` as enabling it requires additional
874 installation and setup. For instructions see
875 :doc:`wutta-continuum:narr/install`.
876 """
877 for provider in self.providers.values():
878 if hasattr(provider, "continuum_is_enabled"):
879 return provider.continuum_is_enabled()
881 return False
883 ##############################
884 # common value renderers
885 ##############################
887 def render_boolean(self, value):
888 """
889 Render a boolean value for display.
891 :param value: A boolean, or ``None``.
893 :returns: Display string for the value.
894 """
895 if value is None:
896 return ""
898 return "Yes" if value else "No"
900 def render_currency(self, value, scale=2):
901 """
902 Return a human-friendly display string for the given currency
903 value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
905 :param value: Either a :class:`python:decimal.Decimal` or
906 :class:`python:float` value.
908 :param scale: Number of decimal digits to be displayed.
910 :returns: Display string for the value.
911 """
912 if value is None:
913 return ""
915 if value < 0:
916 fmt = f"(${{:0,.{scale}f}})"
917 return fmt.format(0 - value)
919 fmt = f"${{:0,.{scale}f}}"
920 return fmt.format(value)
922 display_format_date = "%Y-%m-%d"
923 """
924 Format string to use when displaying :class:`python:datetime.date`
925 objects. See also :meth:`render_date()`.
926 """
928 display_format_datetime = "%Y-%m-%d %H:%M%z"
929 """
930 Format string to use when displaying
931 :class:`python:datetime.datetime` objects. See also
932 :meth:`render_datetime()`.
933 """
935 def render_date(self, value, local=True):
936 """
937 Return a human-friendly display string for the given date.
939 Uses :attr:`display_format_date` to render the value.
941 :param value: Can be a :class:`python:datetime.date` *or*
942 :class:`python:datetime.datetime` instance, or ``None``.
944 :param local: By default the ``value`` will first be passed to
945 :meth:`localtime()` to normalize it for display (but only
946 if value is a ``datetime`` instance). Specify
947 ``local=False`` to skip that and render the value as-is.
949 :returns: Display string.
950 """
951 if value is None:
952 return ""
954 if local and isinstance(value, datetime.datetime):
955 value = self.localtime(value)
957 return value.strftime(self.display_format_date)
959 def render_datetime(self, value, local=True, html=False):
960 """
961 Return a human-friendly display string for the given datetime.
963 Uses :attr:`display_format_datetime` to render the value.
965 :param value: A :class:`python:datetime.datetime` instance (or
966 ``None``).
968 :param local: By default the ``value`` will first be passed to
969 :meth:`localtime()` to normalize it for display. Specify
970 ``local=False`` to skip that and render the value as-is.
972 :param html: If true, return HTML (with tooltip showing
973 relative time delta) instead of plain text.
975 :returns: Rendered datetime as string (or HTML with tooltip).
976 """
977 if value is None:
978 return ""
980 # we usually want to render a "local" time
981 if local:
982 value = self.localtime(value)
984 # simple formatted text
985 text = value.strftime(self.display_format_datetime)
987 if html:
989 # calculate time diff
990 # nb. if both times are naive, they should be UTC;
991 # otherwise if both are zone-aware, this should work even
992 # if they use different zones.
993 delta = self.make_utc(tzinfo=bool(value.tzinfo)) - value
995 # show text w/ time diff as tooltip
996 return HTML.tag("span", c=text, title=self.render_time_ago(delta))
998 return text
1000 def render_error(self, error):
1001 """
1002 Return a "human-friendly" display string for the error, e.g.
1003 when showing it to the user.
1005 By default, this is a convenience wrapper for
1006 :func:`~wuttjamaican.util.simple_error()`.
1007 """
1008 return simple_error(error)
1010 def render_percent(self, value, decimals=2):
1011 """
1012 Return a human-friendly display string for the given
1013 percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
1015 :param value: The value to be rendered.
1017 :returns: Display string for the percentage value.
1018 """
1019 if value is None:
1020 return ""
1021 fmt = f"{{:0.{decimals}f}} %"
1022 if value < 0:
1023 return f"({fmt.format(-value)})"
1024 return fmt.format(value)
1026 def render_quantity(self, value, empty_zero=False):
1027 """
1028 Return a human-friendly display string for the given quantity
1029 value, e.g. ``1.000`` becomes ``"1"``.
1031 :param value: The quantity to be rendered.
1033 :param empty_zero: Affects the display when value equals zero.
1034 If false (the default), will return ``'0'``; if true then
1035 it returns empty string.
1037 :returns: Display string for the quantity.
1038 """
1039 if value is None:
1040 return ""
1041 if int(value) == value:
1042 value = int(value)
1043 if empty_zero and value == 0:
1044 return ""
1045 return f"{value:,}"
1046 return f"{value:,}".rstrip("0")
1048 def render_time_ago(self, value):
1049 """
1050 Return a human-friendly string, indicating how long ago
1051 something occurred.
1053 Default logic uses :func:`humanize:humanize.naturaltime()` for
1054 the rendering.
1056 :param value: Instance of :class:`python:datetime.datetime` or
1057 :class:`python:datetime.timedelta`.
1059 :returns: Text to display.
1060 """
1061 # TODO: this now assumes naive UTC value incoming...
1062 return humanize.naturaltime(value, when=self.make_utc(tzinfo=False))
1064 ##############################
1065 # getters for other handlers
1066 ##############################
1068 def get_auth_handler(self, **kwargs):
1069 """
1070 Get the configured :term:`auth handler`.
1072 :rtype: :class:`~wuttjamaican.auth.AuthHandler`
1073 """
1074 if "auth" not in self.handlers:
1075 spec = self.config.get(
1076 f"{self.appname}.auth.handler", default=self.default_auth_handler_spec
1077 )
1078 factory = self.load_object(spec)
1079 self.handlers["auth"] = factory(self.config, **kwargs)
1080 return self.handlers["auth"]
1082 def get_batch_handler(self, key, default=None, **kwargs):
1083 """
1084 Get the configured :term:`batch handler` for the given type.
1086 :param key: Unique key designating the :term:`batch type`.
1088 :param default: Spec string to use as the default, if none is
1089 configured.
1091 :returns: :class:`~wuttjamaican.batch.BatchHandler` instance
1092 for the requested type. If no spec can be determined, a
1093 ``KeyError`` is raised.
1094 """
1095 spec = self.config.get(
1096 f"{self.appname}.batch.{key}.handler.spec", default=default
1097 )
1098 if not spec:
1099 spec = self.config.get(f"{self.appname}.batch.{key}.handler.default_spec")
1100 if not spec:
1101 raise KeyError(f"handler spec not found for batch key: {key}")
1102 factory = self.load_object(spec)
1103 return factory(self.config, **kwargs)
1105 def get_batch_handler_specs(self, key, default=None):
1106 """
1107 Get the :term:`spec` strings for all available handlers of the
1108 given batch type.
1110 :param key: Unique key designating the :term:`batch type`.
1112 :param default: Default spec string(s) to include, even if not
1113 registered. Can be a string or list of strings.
1115 :returns: List of batch handler spec strings.
1117 This will gather available spec strings from the following:
1119 First, the ``default`` as provided by caller.
1121 Second, the default spec from config, if set; for example:
1123 .. code-block:: ini
1125 [wutta.batch]
1126 inventory.handler.default_spec = poser.batch.inventory:InventoryBatchHandler
1128 Third, each spec registered via entry points. For instance in
1129 ``pyproject.toml``:
1131 .. code-block:: toml
1133 [project.entry-points."wutta.batch.inventory"]
1134 poser = "poser.batch.inventory:InventoryBatchHandler"
1136 The final list will be "sorted" according to the above, with
1137 the latter registered handlers being sorted alphabetically.
1138 """
1139 handlers = []
1141 # defaults from caller
1142 if isinstance(default, str):
1143 handlers.append(default)
1144 elif default:
1145 handlers.extend(default)
1147 # configured default, if applicable
1148 default = self.config.get(
1149 f"{self.config.appname}.batch.{key}.handler.default_spec"
1150 )
1151 if default and default not in handlers:
1152 handlers.append(default)
1154 # registered via entry points
1155 registered = []
1156 for handler in load_entry_points(f"{self.appname}.batch.{key}").values():
1157 spec = handler.get_spec()
1158 if spec not in handlers:
1159 registered.append(spec)
1160 if registered:
1161 registered.sort()
1162 handlers.extend(registered)
1164 return handlers
1166 def get_cleanup_handler(self):
1167 """
1168 Get the configured :term:`cleanup handler`.
1170 :rtype: :class:`~wuttjamaican.cleanup.CleanupHandler`
1171 """
1172 if "cleanup" not in self.handlers:
1173 spec = self.config.get(
1174 f"{self.appname}.cleanup.handler",
1175 default=self.default_cleanup_handler_spec,
1176 )
1177 log.debug("cleanup handler spec is %s", spec)
1178 factory = self.load_object(spec)
1179 self.handlers["cleanup"] = factory(self.config)
1180 return self.handlers["cleanup"]
1182 def get_db_handler(self, **kwargs):
1183 """
1184 Get the configured :term:`db handler`.
1186 :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler`
1187 """
1188 if "db" not in self.handlers:
1189 spec = self.config.get(
1190 f"{self.appname}.db.handler", default=self.default_db_handler_spec
1191 )
1192 factory = self.load_object(spec)
1193 self.handlers["db"] = factory(self.config, **kwargs)
1194 return self.handlers["db"]
1196 def get_email_handler(self, **kwargs):
1197 """
1198 Get the configured :term:`email handler`.
1200 See also :meth:`send_email()`.
1202 :rtype: :class:`~wuttjamaican.email.EmailHandler`
1203 """
1204 if "email" not in self.handlers:
1205 spec = self.config.get(
1206 f"{self.appname}.email.handler", default=self.default_email_handler_spec
1207 )
1208 factory = self.load_object(spec)
1209 self.handlers["email"] = factory(self.config, **kwargs)
1210 return self.handlers["email"]
1212 def get_install_handler(self, **kwargs):
1213 """
1214 Get the configured :term:`install handler`.
1216 :rtype: :class:`~wuttjamaican.install.InstallHandler`
1217 """
1218 if "install" not in self.handlers:
1219 spec = self.config.get(
1220 f"{self.appname}.install.handler",
1221 default=self.default_install_handler_spec,
1222 )
1223 factory = self.load_object(spec)
1224 self.handlers["install"] = factory(self.config, **kwargs)
1225 return self.handlers["install"]
1227 def get_people_handler(self, **kwargs):
1228 """
1229 Get the configured :term:`people handler`.
1231 :rtype: :class:`~wuttjamaican.people.PeopleHandler`
1232 """
1233 if "people" not in self.handlers:
1234 spec = self.config.get(
1235 f"{self.appname}.people.handler",
1236 default=self.default_people_handler_spec,
1237 )
1238 factory = self.load_object(spec)
1239 self.handlers["people"] = factory(self.config, **kwargs)
1240 return self.handlers["people"]
1242 def get_problem_handler(self, **kwargs):
1243 """
1244 Get the configured :term:`problem handler`.
1246 :rtype: :class:`~wuttjamaican.problems.ProblemHandler`
1247 """
1248 if "problems" not in self.handlers:
1249 spec = self.config.get(
1250 f"{self.appname}.problems.handler",
1251 default=self.default_problem_handler_spec,
1252 )
1253 log.debug("problem handler spec is %s", spec)
1254 factory = self.load_object(spec)
1255 self.handlers["problems"] = factory(self.config, **kwargs)
1256 return self.handlers["problems"]
1258 def get_report_handler(self, **kwargs):
1259 """
1260 Get the configured :term:`report handler`.
1262 :rtype: :class:`~wuttjamaican.reports.ReportHandler`
1263 """
1264 if "reports" not in self.handlers:
1265 spec = self.config.get(
1266 f"{self.appname}.reports.handler_spec",
1267 default=self.default_report_handler_spec,
1268 )
1269 factory = self.load_object(spec)
1270 self.handlers["reports"] = factory(self.config, **kwargs)
1271 return self.handlers["reports"]
1273 def get_upgrade_handler(self, **kwargs):
1274 """
1275 Get the configured :term:`upgrade handler`.
1277 :rtype: :class:`~wuttjamaican.upgrades.UpgradeHandler`
1278 """
1279 if "upgrade" not in self.handlers:
1280 spec = self.config.get(
1281 f"{self.appname}.upgrades.handler_spec",
1282 default=self.default_upgrade_handler_spec,
1283 )
1284 log.debug("upgrade handler spec is %s", spec)
1285 factory = self.load_object(spec)
1286 self.handlers["upgrade"] = factory(self.config, **kwargs)
1287 return self.handlers["upgrade"]
1289 ##############################
1290 # convenience delegators
1291 ##############################
1293 def get_person(self, obj, **kwargs):
1294 """
1295 Convenience method to locate a
1296 :class:`~wuttjamaican.db.model.base.Person` for the given
1297 object.
1299 This delegates to the "people" handler method,
1300 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
1301 """
1302 return self.get_people_handler().get_person(obj, **kwargs)
1304 def send_email(self, *args, **kwargs):
1305 """
1306 Send an email message.
1308 This is a convenience wrapper around
1309 :meth:`~wuttjamaican.email.EmailHandler.send_email()`.
1310 """
1311 self.get_email_handler().send_email(*args, **kwargs)
1314class AppProvider: # pylint: disable=too-few-public-methods
1315 """
1316 Base class for :term:`app providers<app provider>`.
1318 These can add arbitrary extra functionality to the main :term:`app
1319 handler`. See also :doc:`/narr/providers/app`.
1321 :param config: The app :term:`config object`.
1323 ``AppProvider`` instances have the following attributes:
1325 .. attribute:: config
1327 Reference to the config object.
1329 .. attribute:: app
1331 Reference to the parent app handler.
1333 Some things which a subclass may define, in order to register
1334 various features with the app:
1336 .. attribute:: email_modules
1338 List of :term:`email modules <email module>` provided. Should
1339 be a list of strings; each is a dotted module path, e.g.::
1341 email_modules = ['poser.emails']
1343 .. attribute:: email_templates
1345 List of :term:`email template` folders provided. Can be a list
1346 of paths, or a single path as string::
1348 email_templates = ['poser:templates/email']
1350 email_templates = 'poser:templates/email'
1352 Note the syntax, which specifies python module, then colon
1353 (``:``), then filesystem path below that. However absolute
1354 file paths may be used as well, when applicable.
1355 """
1357 def __init__(self, config):
1358 if isinstance(config, AppHandler):
1359 warnings.warn(
1360 "passing app handler to app provider is deprecated; "
1361 "must pass config object instead",
1362 DeprecationWarning,
1363 stacklevel=2,
1364 )
1365 config = config.config
1367 self.config = config
1368 self.app = self.config.get_app()
1370 @property
1371 def appname(self):
1372 """
1373 The :term:`app name` for the current app.
1375 See also :attr:`AppHandler.appname`.
1376 """
1377 return self.app.appname
1380class GenericHandler:
1381 """
1382 Generic base class for handlers.
1384 When the :term:`app` defines a new *type* of :term:`handler` it
1385 may subclass this when defining the handler base class.
1387 :param config: Config object for the app. This should be an
1388 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
1389 """
1391 def __init__(self, config):
1392 self.config = config
1393 self.app = self.config.get_app()
1394 self.modules = {}
1395 self.classes = {}
1397 @property
1398 def appname(self):
1399 """
1400 The :term:`app name` for the current app.
1402 See also :attr:`AppHandler.appname`.
1403 """
1404 return self.app.appname
1406 @classmethod
1407 def get_spec(cls):
1408 """
1409 :returns: The class :term:`spec` string for the handler.
1410 """
1411 return f"{cls.__module__}:{cls.__name__}"
1413 def get_provider_modules(self, module_type):
1414 """
1415 Returns a list of all available modules of the given type.
1417 Not all handlers would need such a thing, but notable ones
1418 which do are the :term:`email handler` and :term:`report
1419 handler`. Both can obtain classes (emails or reports) from
1420 arbitrary modules, and this method is used to locate them.
1422 This will discover all modules exposed by the app
1423 :term:`providers <provider>`, which expose an attribute with
1424 name like ``f"{module_type}_modules"``.
1426 :param module_type: Unique name referring to a particular
1427 "type" of modules to locate, e.g. ``'email'``.
1429 :returns: List of module objects.
1430 """
1431 if module_type not in self.modules:
1432 self.modules[module_type] = []
1433 for provider in self.app.providers.values():
1434 name = f"{module_type}_modules"
1435 if hasattr(provider, name):
1436 modules = getattr(provider, name)
1437 if modules:
1438 if isinstance(modules, str):
1439 modules = [modules]
1440 for modpath in modules:
1441 module = importlib.import_module(modpath)
1442 self.modules[module_type].append(module)
1443 return self.modules[module_type]