Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/app.py: 100%
354 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-01-06 22:51 -0600
« prev ^ index » next coverage.py v7.11.0, created at 2026-01-06 22:51 -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 localtime,
42 load_entry_points,
43 load_object,
44 make_title,
45 make_full_name,
46 make_utc,
47 make_uuid,
48 make_str_uuid,
49 make_true_uuid,
50 progress_loop,
51 resource_path,
52 simple_error,
53)
56log = logging.getLogger(__name__)
59class AppHandler: # pylint: disable=too-many-public-methods
60 """
61 Base class and default implementation for top-level :term:`app
62 handler`.
64 aka. "the handler to handle all handlers"
66 aka. "one handler to bind them all"
68 For more info see :doc:`/narr/handlers/app`.
70 There is normally no need to create one of these yourself; rather
71 you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()`
72 on the :term:`config object` if you need the app handler.
74 :param config: Config object for the app. This should be an
75 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
77 .. attribute:: model
79 Reference to the :term:`app model` module.
81 Note that :meth:`get_model()` is responsible for determining
82 which module this will point to. However you can always get
83 the model using this attribute (e.g. ``app.model``) and do not
84 need to call :meth:`get_model()` yourself - that part will
85 happen automatically.
87 .. attribute:: enum
89 Reference to the :term:`app enum` module.
91 Note that :meth:`get_enum()` is responsible for determining
92 which module this will point to. However you can always get
93 the model using this attribute (e.g. ``app.enum``) and do not
94 need to call :meth:`get_enum()` yourself - that part will
95 happen automatically.
97 .. attribute:: providers
99 Dictionary of :class:`AppProvider` instances, as returned by
100 :meth:`get_all_providers()`.
101 """
103 default_app_title = "WuttJamaican"
104 default_model_spec = "wuttjamaican.db.model"
105 default_enum_spec = "wuttjamaican.enum"
106 default_auth_handler_spec = "wuttjamaican.auth:AuthHandler"
107 default_db_handler_spec = "wuttjamaican.db.handler:DatabaseHandler"
108 default_email_handler_spec = "wuttjamaican.email:EmailHandler"
109 default_install_handler_spec = "wuttjamaican.install:InstallHandler"
110 default_people_handler_spec = "wuttjamaican.people:PeopleHandler"
111 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler"
112 default_report_handler_spec = "wuttjamaican.reports:ReportHandler"
114 def __init__(self, config):
115 self.config = config
116 self.handlers = {}
117 self.timezones = {}
119 @property
120 def appname(self):
121 """
122 The :term:`app name` for the current app. This is just an
123 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`.
125 Note that this ``appname`` does not necessariy reflect what
126 you think of as the name of your (e.g. custom) app. It is
127 more fundamental than that; your Python package naming and the
128 :term:`app title` are free to use a different name as their
129 basis.
130 """
131 return self.config.appname
133 def __getattr__(self, name):
134 """
135 Custom attribute getter, called when the app handler does not
136 already have an attribute with the given ``name``.
138 This will delegate to the set of :term:`app providers<app
139 provider>`; the first provider with an appropriately-named
140 attribute wins, and that value is returned.
142 :returns: The first value found among the set of app
143 providers.
144 """
146 if name == "model":
147 return self.get_model()
149 if name == "enum":
150 return self.get_enum()
152 if name == "providers":
153 self.__dict__["providers"] = self.get_all_providers()
154 return self.providers
156 for provider in self.providers.values():
157 if hasattr(provider, name):
158 return getattr(provider, name)
160 raise AttributeError(f"attr not found: {name}")
162 def get_all_providers(self):
163 """
164 Load and return all registered providers.
166 Note that you do not need to call this directly; instead just
167 use :attr:`providers`.
169 The discovery logic is based on :term:`entry points<entry
170 point>` using the ``wutta.app.providers`` group. For instance
171 here is a sample entry point used by WuttaWeb (in its
172 ``pyproject.toml``):
174 .. code-block:: toml
176 [project.entry-points."wutta.app.providers"]
177 wuttaweb = "wuttaweb.app:WebAppProvider"
179 :returns: Dictionary keyed by entry point name; values are
180 :class:`AppProvider` instances.
181 """
182 # nb. must use 'wutta' and not self.appname prefix here, or
183 # else we can't find all providers with custom appname
184 providers = load_entry_points("wutta.app.providers")
185 for key in list(providers):
186 providers[key] = providers[key](self.config)
187 return providers
189 def get_title(self, default=None):
190 """
191 Returns the configured title for the app.
193 :param default: Value to be returned if there is no app title
194 configured.
196 :returns: Title for the app.
197 """
198 return self.config.get(
199 f"{self.appname}.app_title", default=default or self.default_app_title
200 )
202 def get_node_title(self, default=None):
203 """
204 Returns the configured title for the local app node.
206 If none is configured, and no default provided, will return
207 the value from :meth:`get_title()`.
209 :param default: Value to use if the node title is not
210 configured.
212 :returns: Title for the local app node.
213 """
214 title = self.config.get(f"{self.appname}.node_title")
215 if title:
216 return title
217 return self.get_title(default=default)
219 def get_node_type(self, default=None):
220 """
221 Returns the "type" of current app node.
223 The framework itself does not (yet?) have any notion of what a
224 node type means. This abstraction is here for convenience, in
225 case it is needed by a particular app ecosystem.
227 :returns: String name for the node type, or ``None``.
229 The node type must be configured via file; this cannot be done
230 with a DB setting. Depending on :attr:`appname` that is like
231 so:
233 .. code-block:: ini
235 [wutta]
236 node_type = warehouse
237 """
238 return self.config.get(
239 f"{self.appname}.node_type", default=default, usedb=False
240 )
242 def get_distribution(self, obj=None):
243 """
244 Returns the appropriate Python distribution name.
246 If ``obj`` is specified, this will attempt to locate the
247 distribution based on the top-level module which contains the
248 object's type/class.
250 If ``obj`` is *not* specified, this behaves a bit differently.
251 It first will look for a :term:`config setting` named
252 ``wutta.app_dist`` (or similar, depending on :attr:`appname`).
253 If there is such a config value, it is returned. Otherwise
254 the "auto-locate" logic described above happens, but using
255 ``self`` instead of ``obj``.
257 In other words by default this returns the distribution to
258 which the running :term:`app handler` belongs.
260 See also :meth:`get_version()`.
262 :param obj: Any object which may be used as a clue to locate
263 the appropriate distribution.
265 :returns: string, or ``None``
267 Also note that a *distribution* name is different from a
268 *package* name. The distribution name is how things appear on
269 PyPI for instance.
271 If you want to override the default distribution name (and
272 skip the auto-locate based on app handler) then you can define
273 it in config:
275 .. code-block:: ini
277 [wutta]
278 app_dist = My-Poser-Dist
279 """
280 if obj is None:
281 dist = self.config.get(f"{self.appname}.app_dist")
282 if dist:
283 return dist
285 # TODO: do we need a config setting for app_package ?
286 # modpath = self.config.get(f'{self.appname}.app_package')
287 modpath = None
288 if not modpath:
289 modpath = type(obj if obj is not None else self).__module__
290 pkgname = modpath.split(".")[0]
292 try:
293 from importlib.metadata import ( # pylint: disable=import-outside-toplevel
294 packages_distributions,
295 )
296 except ImportError: # python < 3.10
297 from importlib_metadata import ( # pylint: disable=import-outside-toplevel
298 packages_distributions,
299 )
301 pkgmap = packages_distributions()
302 if pkgname in pkgmap:
303 dist = pkgmap[pkgname][0]
304 return dist
306 # fall back to configured dist, if obj lookup failed
307 return self.config.get(f"{self.appname}.app_dist")
309 def get_version(self, dist=None, obj=None):
310 """
311 Returns the version of a given Python distribution.
313 If ``dist`` is not specified, calls :meth:`get_distribution()`
314 to get it. (It passes ``obj`` along for this).
316 So by default this will return the version of whichever
317 distribution owns the running :term:`app handler`.
319 :returns: Version as string.
320 """
321 if not dist:
322 dist = self.get_distribution(obj=obj)
323 if dist:
324 return version(dist)
325 return None
327 def get_model(self):
328 """
329 Returns the :term:`app model` module.
331 Note that you don't actually need to call this method; you can
332 get the model by simply accessing :attr:`model`
333 (e.g. ``app.model``) instead.
335 By default this will return :mod:`wuttjamaican.db.model`
336 unless the config class or some :term:`config extension` has
337 provided another default.
339 A custom app can override the default like so (within a config
340 extension)::
342 config.setdefault('wutta.model_spec', 'poser.db.model')
343 """
344 if "model" not in self.__dict__:
345 spec = self.config.get(
346 f"{self.appname}.model_spec",
347 usedb=False,
348 default=self.default_model_spec,
349 )
350 self.__dict__["model"] = importlib.import_module(spec)
351 return self.model
353 def get_enum(self):
354 """
355 Returns the :term:`app enum` module.
357 Note that you don't actually need to call this method; you can
358 get the module by simply accessing :attr:`enum`
359 (e.g. ``app.enum``) instead.
361 By default this will return :mod:`wuttjamaican.enum` unless
362 the config class or some :term:`config extension` has provided
363 another default.
365 A custom app can override the default like so (within a config
366 extension)::
368 config.setdefault('wutta.enum_spec', 'poser.enum')
369 """
370 if "enum" not in self.__dict__:
371 spec = self.config.get(
372 f"{self.appname}.enum_spec", usedb=False, default=self.default_enum_spec
373 )
374 self.__dict__["enum"] = importlib.import_module(spec)
375 return self.enum
377 def load_object(self, spec):
378 """
379 Import and/or load and return the object designated by the
380 given spec string.
382 This invokes :func:`wuttjamaican.util.load_object()`.
384 :param spec: String of the form ``module.dotted.path:objname``.
386 :returns: The object referred to by ``spec``. If the module
387 could not be imported, or did not contain an object of the
388 given name, then an error will raise.
389 """
390 return load_object(spec)
392 def get_appdir(self, *args, **kwargs):
393 """
394 Returns path to the :term:`app dir`.
396 This does not check for existence of the path, it only reads
397 it from config or (optionally) provides a default path.
399 :param configured_only: Pass ``True`` here if you only want
400 the configured path and ignore the default path.
402 :param create: Pass ``True`` here if you want to ensure the
403 returned path exists, creating it if necessary.
405 :param \\*args: Any additional args will be added as child
406 paths for the final value.
408 For instance, assuming ``/srv/envs/poser`` is the virtual
409 environment root::
411 app.get_appdir() # => /srv/envs/poser/app
413 app.get_appdir('data') # => /srv/envs/poser/app/data
414 """
415 configured_only = kwargs.pop("configured_only", False)
416 create = kwargs.pop("create", False)
418 # maybe specify default path
419 if not configured_only:
420 path = os.path.join(sys.prefix, "app")
421 kwargs.setdefault("default", path)
423 # get configured path
424 kwargs.setdefault("usedb", False)
425 path = self.config.get(f"{self.appname}.appdir", **kwargs)
427 # add any subpath info
428 if path and args:
429 path = os.path.join(path, *args)
431 # create path if requested/needed
432 if create:
433 if not path:
434 raise ValueError("appdir path unknown! so cannot create it.")
435 if not os.path.exists(path):
436 os.makedirs(path)
438 return path
440 def make_appdir(self, path, subfolders=None):
441 """
442 Establish an :term:`app dir` at the given path.
444 Default logic only creates a few subfolders, meant to help
445 steer the admin toward a convention for sake of where to put
446 things. But custom app handlers are free to do whatever.
448 :param path: Path to the desired app dir. If the path does
449 not yet exist then it will be created. But regardless it
450 should be "refreshed" (e.g. missing subfolders created)
451 when this method is called.
453 :param subfolders: Optional list of subfolder names to create
454 within the app dir. If not specified, defaults will be:
455 ``['cache', 'data', 'log', 'work']``.
456 """
457 appdir = path
458 if not os.path.exists(appdir):
459 os.makedirs(appdir)
461 if not subfolders:
462 subfolders = ["cache", "data", "log", "work"]
464 for name in subfolders:
465 path = os.path.join(appdir, name)
466 if not os.path.exists(path):
467 os.mkdir(path)
469 def render_mako_template(
470 self,
471 template,
472 context,
473 output_path=None,
474 ):
475 """
476 Convenience method to render a Mako template.
478 :param template: :class:`~mako:mako.template.Template`
479 instance.
481 :param context: Dict of context for the template.
483 :param output_path: Optional path to which output should be
484 written.
486 :returns: Rendered output as string.
487 """
488 output = template.render(**context)
489 if output_path:
490 with open(output_path, "wt", encoding="utf_8") as f:
491 f.write(output)
492 return output
494 def resource_path(self, path):
495 """
496 Convenience wrapper for
497 :func:`wuttjamaican.util.resource_path()`.
498 """
499 return resource_path(path)
501 def make_session(self, **kwargs):
502 """
503 Creates a new SQLAlchemy session for the app DB. By default
504 this will create a new :class:`~wuttjamaican.db.sess.Session`
505 instance.
507 :returns: SQLAlchemy session for the app DB.
508 """
509 from .db import Session # pylint: disable=import-outside-toplevel
511 return Session(**kwargs)
513 def make_title(self, text):
514 """
515 Return a human-friendly "title" for the given text.
517 This is mostly useful for converting a Python variable name (or
518 similar) to a human-friendly string, e.g.::
520 make_title('foo_bar') # => 'Foo Bar'
522 By default this just invokes
523 :func:`wuttjamaican.util.make_title()`.
524 """
525 return make_title(text)
527 def make_full_name(self, *parts):
528 """
529 Make a "full name" from the given parts.
531 This is a convenience wrapper around
532 :func:`~wuttjamaican.util.make_full_name()`.
533 """
534 return make_full_name(*parts)
536 def get_timezone(self, key="default"):
537 """
538 Get the configured (or system default) timezone object.
540 This checks config for a setting which corresponds to the
541 given ``key``, then calls
542 :func:`~wuttjamaican.util.get_timezone_by_name()` to get the
543 actual timezone object.
545 The default key corresponds to the true "local" timezone, but
546 other keys may correspond to other configured timezones (if
547 applicable).
549 As a special case for the default key only: If no config value
550 is found, Python itself will determine the default system
551 local timezone.
553 For any non-default key, an error is raised if no config value
554 is found.
556 .. note::
558 The app handler *caches* all timezone objects, to avoid
559 unwanted repetitive lookups when processing multiple
560 datetimes etc. (Since this method is called by
561 :meth:`localtime()`.) Therefore whenever timezone config
562 values are changed, an app restart will be necessary.
564 Example config:
566 .. code-block:: ini
568 [wutta]
569 timezone.default = America/Chicago
570 timezone.westcoast = America/Los_Angeles
572 Example usage::
574 tz_default = app.get_timezone()
575 tz_westcoast = app.get_timezone("westcoast")
577 See also :meth:`get_timezone_name()`.
579 :param key: Config key for desired timezone.
581 :returns: :class:`python:datetime.tzinfo` instance
582 """
583 if key not in self.timezones:
584 setting = f"{self.appname}.timezone.{key}"
585 tzname = self.config.get(setting)
586 if tzname:
587 self.timezones[key] = get_timezone_by_name(tzname)
589 elif key == "default":
590 # fallback to system default
591 self.timezones[key] = datetime.datetime.now().astimezone().tzinfo
593 else:
594 # alternate key was specified, but no config found, so check
595 # again with require() to force error
596 self.timezones[key] = self.config.require(setting)
598 return self.timezones[key]
600 def get_timezone_name(self, key="default"):
601 """
602 Get the display name for the configured (or system default)
603 timezone.
605 This calls :meth:`get_timezone()` and then uses some
606 heuristics to determine the name.
608 :param key: Config key for desired timezone.
610 :returns: String name for the timezone.
611 """
612 tz = self.get_timezone(key=key)
613 try:
614 # TODO: this should work for zoneinfo.ZoneInfo objects,
615 # but not sure yet about dateutils.tz ?
616 return tz.key
617 except AttributeError:
618 # this should work for system default fallback, afaik
619 dt = datetime.datetime.now(tz)
620 return dt.tzname()
622 def localtime(self, dt=None, local_zone=None, **kw):
623 """
624 This produces a datetime in the "local" timezone.
626 This is a convenience wrapper around
627 :func:`~wuttjamaican.util.localtime()`; however it also calls
628 :meth:`get_timezone()` to override the ``local_zone`` param
629 (unless caller specifies that).
631 For usage examples see :ref:`convert-to-localtime`.
633 See also :meth:`make_utc()` which is sort of the inverse; and
634 :meth:`today()`.
635 """
636 kw["local_zone"] = local_zone or self.get_timezone()
637 return localtime(dt=dt, **kw)
639 def make_utc(self, dt=None, tzinfo=False):
640 """
641 This returns a datetime local to the UTC timezone. It is a
642 convenience wrapper around
643 :func:`~wuttjamaican.util.make_utc()`.
645 For usage examples see :ref:`convert-to-utc`.
647 See also :meth:`localtime()` which is sort of the inverse.
648 """
649 return make_utc(dt=dt, tzinfo=tzinfo)
651 def today(self):
652 """
653 Convenience method to return the current date, according
654 to local time zone.
656 See also :meth:`localtime()`.
658 :returns: :class:`python:datetime.date` instance
659 """
660 return self.localtime().date()
662 # TODO: deprecate / remove this eventually
663 def make_true_uuid(self):
664 """
665 Generate a new :term:`UUID <uuid>`.
667 This is a convenience around
668 :func:`~wuttjamaican.util.make_true_uuid()`.
670 See also :meth:`make_uuid()`.
672 :returns: :class:`python:uuid.UUID` instance
673 """
674 return make_true_uuid()
676 # TODO: deprecate / remove this eventually
677 def make_str_uuid(self):
678 """
679 Generate a new :term:`UUID <uuid>` string.
681 This is a convenience around
682 :func:`~wuttjamaican.util.make_str_uuid()`.
684 See also :meth:`make_uuid()`.
686 :returns: UUID value as 32-character string.
687 """
688 return make_str_uuid()
690 # TODO: eventually refactor, to return true uuid
691 def make_uuid(self):
692 """
693 Generate a new :term:`UUID <uuid>` (for now, as string).
695 This is a convenience around
696 :func:`~wuttjamaican.util.make_uuid()`.
698 :returns: UUID as 32-character hex string
700 .. warning::
702 **TEMPORARY BEHAVIOR**
704 For the moment, use of this method is discouraged. Instead
705 you should use :meth:`make_true_uuid()` or
706 :meth:`make_str_uuid()` to be explicit about the return
707 type you expect.
709 *Eventually* (once it's clear most/all callers are using
710 the explicit methods) this will be refactored to return a
711 UUID instance. But for now this method returns a string.
712 """
713 warnings.warn(
714 "app.make_uuid() is temporarily deprecated, in favor of "
715 "explicit methods, app.make_true_uuid() and app.make_str_uuid()",
716 DeprecationWarning,
717 stacklevel=2,
718 )
719 return make_uuid()
721 def progress_loop(self, *args, **kwargs):
722 """
723 Convenience method to iterate over a set of items, invoking
724 logic for each, and updating a progress indicator along the
725 way.
727 This is a wrapper around
728 :func:`wuttjamaican.util.progress_loop()`; see those docs for
729 param details.
730 """
731 return progress_loop(*args, **kwargs)
733 def get_session(self, obj):
734 """
735 Returns the SQLAlchemy session with which the given object is
736 associated. Simple convenience wrapper around
737 :func:`sqlalchemy:sqlalchemy.orm.object_session()`.
738 """
739 from sqlalchemy import orm # pylint: disable=import-outside-toplevel
741 return orm.object_session(obj)
743 def short_session(self, **kwargs):
744 """
745 Returns a context manager for a short-lived database session.
747 This is a convenience wrapper around
748 :class:`~wuttjamaican.db.sess.short_session`.
750 If caller does not specify ``factory`` nor ``config`` params,
751 this method will provide a default factory in the form of
752 :meth:`make_session`.
753 """
754 from .db import short_session # pylint: disable=import-outside-toplevel
756 if "factory" not in kwargs and "config" not in kwargs:
757 kwargs["factory"] = self.make_session
759 return short_session(**kwargs)
761 def get_setting(self, session, name, **kwargs): # pylint: disable=unused-argument
762 """
763 Get a :term:`config setting` value from the DB.
765 This does *not* consult the :term:`config object` directly to
766 determine the setting value; it always queries the DB.
768 Default implementation is just a convenience wrapper around
769 :func:`~wuttjamaican.db.conf.get_setting()`.
771 See also :meth:`save_setting()` and :meth:`delete_setting()`.
773 :param session: App DB session.
775 :param name: Name of the setting to get.
777 :param \\**kwargs: Any remaining kwargs are ignored by the
778 default logic, but subclass may override.
780 :returns: Setting value as string, or ``None``.
781 """
782 from .db import get_setting # pylint: disable=import-outside-toplevel
784 return get_setting(session, name)
786 def save_setting(
787 self, session, name, value, force_create=False, **kwargs
788 ): # pylint: disable=unused-argument
789 """
790 Save a :term:`config setting` value to the DB.
792 See also :meth:`get_setting()` and :meth:`delete_setting()`.
794 :param session: Current :term:`db session`.
796 :param name: Name of the setting to save.
798 :param value: Value to be saved for the setting; should be
799 either a string or ``None``.
801 :param force_create: If ``False`` (the default) then logic
802 will first try to locate an existing setting of the same
803 name, and update it if found, or create if not.
805 But if this param is ``True`` then logic will only try to
806 create a new record, and not bother checking to see if it
807 exists.
809 (Theoretically the latter offers a slight efficiency gain.)
811 :param \\**kwargs: Any remaining kwargs are ignored by the
812 default logic, but subclass may override.
813 """
814 model = self.model
816 # maybe fetch existing setting
817 setting = None
818 if not force_create:
819 setting = session.get(model.Setting, name)
821 # create setting if needed
822 if not setting:
823 setting = model.Setting(name=name)
824 session.add(setting)
826 # set value
827 setting.value = value
829 def delete_setting(
830 self, session, name, **kwargs
831 ): # pylint: disable=unused-argument
832 """
833 Delete a :term:`config setting` from the DB.
835 See also :meth:`get_setting()` and :meth:`save_setting()`.
837 :param session: Current :term:`db session`.
839 :param name: Name of the setting to delete.
841 :param \\**kwargs: Any remaining kwargs are ignored by the
842 default logic, but subclass may override.
843 """
844 model = self.model
845 setting = session.get(model.Setting, name)
846 if setting:
847 session.delete(setting)
849 def continuum_is_enabled(self):
850 """
851 Returns boolean indicating if Wutta-Continuum is installed and
852 enabled.
854 Default will be ``False`` as enabling it requires additional
855 installation and setup. For instructions see
856 :doc:`wutta-continuum:narr/install`.
857 """
858 for provider in self.providers.values():
859 if hasattr(provider, "continuum_is_enabled"):
860 return provider.continuum_is_enabled()
862 return False
864 ##############################
865 # common value renderers
866 ##############################
868 def render_boolean(self, value):
869 """
870 Render a boolean value for display.
872 :param value: A boolean, or ``None``.
874 :returns: Display string for the value.
875 """
876 if value is None:
877 return ""
879 return "Yes" if value else "No"
881 def render_currency(self, value, scale=2):
882 """
883 Return a human-friendly display string for the given currency
884 value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
886 :param value: Either a :class:`python:decimal.Decimal` or
887 :class:`python:float` value.
889 :param scale: Number of decimal digits to be displayed.
891 :returns: Display string for the value.
892 """
893 if value is None:
894 return ""
896 if value < 0:
897 fmt = f"(${{:0,.{scale}f}})"
898 return fmt.format(0 - value)
900 fmt = f"${{:0,.{scale}f}}"
901 return fmt.format(value)
903 display_format_date = "%Y-%m-%d"
904 """
905 Format string to use when displaying :class:`python:datetime.date`
906 objects. See also :meth:`render_date()`.
907 """
909 display_format_datetime = "%Y-%m-%d %H:%M%z"
910 """
911 Format string to use when displaying
912 :class:`python:datetime.datetime` objects. See also
913 :meth:`render_datetime()`.
914 """
916 def render_date(self, value):
917 """
918 Return a human-friendly display string for the given date.
920 Uses :attr:`display_format_date` to render the value.
922 :param value: A :class:`python:datetime.date` instance (or
923 ``None``).
925 :returns: Display string.
926 """
927 if value is None:
928 return ""
929 return value.strftime(self.display_format_date)
931 def render_datetime(self, value, local=True, html=False):
932 """
933 Return a human-friendly display string for the given datetime.
935 Uses :attr:`display_format_datetime` to render the value.
937 :param value: A :class:`python:datetime.datetime` instance (or
938 ``None``).
940 :param local: By default the ``value`` will first be passed to
941 :meth:`localtime()` to normalize it for display. Specify
942 ``local=False`` to skip that and render the value as-is.
944 :param html: If true, return HTML (with tooltip showing
945 relative time delta) instead of plain text.
947 :returns: Rendered datetime as string (or HTML with tooltip).
948 """
949 if value is None:
950 return ""
952 # we usually want to render a "local" time
953 if local:
954 value = self.localtime(value)
956 # simple formatted text
957 text = value.strftime(self.display_format_datetime)
959 if html:
961 # calculate time diff
962 # nb. if both times are naive, they should be UTC;
963 # otherwise if both are zone-aware, this should work even
964 # if they use different zones.
965 delta = self.make_utc(tzinfo=bool(value.tzinfo)) - value
967 # show text w/ time diff as tooltip
968 return HTML.tag("span", c=text, title=self.render_time_ago(delta))
970 return text
972 def render_error(self, error):
973 """
974 Return a "human-friendly" display string for the error, e.g.
975 when showing it to the user.
977 By default, this is a convenience wrapper for
978 :func:`~wuttjamaican.util.simple_error()`.
979 """
980 return simple_error(error)
982 def render_percent(self, value, decimals=2):
983 """
984 Return a human-friendly display string for the given
985 percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
987 :param value: The value to be rendered.
989 :returns: Display string for the percentage value.
990 """
991 if value is None:
992 return ""
993 fmt = f"{{:0.{decimals}f}} %"
994 if value < 0:
995 return f"({fmt.format(-value)})"
996 return fmt.format(value)
998 def render_quantity(self, value, empty_zero=False):
999 """
1000 Return a human-friendly display string for the given quantity
1001 value, e.g. ``1.000`` becomes ``"1"``.
1003 :param value: The quantity to be rendered.
1005 :param empty_zero: Affects the display when value equals zero.
1006 If false (the default), will return ``'0'``; if true then
1007 it returns empty string.
1009 :returns: Display string for the quantity.
1010 """
1011 if value is None:
1012 return ""
1013 if int(value) == value:
1014 value = int(value)
1015 if empty_zero and value == 0:
1016 return ""
1017 return f"{value:,}"
1018 return f"{value:,}".rstrip("0")
1020 def render_time_ago(self, value):
1021 """
1022 Return a human-friendly string, indicating how long ago
1023 something occurred.
1025 Default logic uses :func:`humanize:humanize.naturaltime()` for
1026 the rendering.
1028 :param value: Instance of :class:`python:datetime.datetime` or
1029 :class:`python:datetime.timedelta`.
1031 :returns: Text to display.
1032 """
1033 # TODO: this now assumes naive UTC value incoming...
1034 return humanize.naturaltime(value, when=self.make_utc(tzinfo=False))
1036 ##############################
1037 # getters for other handlers
1038 ##############################
1040 def get_auth_handler(self, **kwargs):
1041 """
1042 Get the configured :term:`auth handler`.
1044 :rtype: :class:`~wuttjamaican.auth.AuthHandler`
1045 """
1046 if "auth" not in self.handlers:
1047 spec = self.config.get(
1048 f"{self.appname}.auth.handler", default=self.default_auth_handler_spec
1049 )
1050 factory = self.load_object(spec)
1051 self.handlers["auth"] = factory(self.config, **kwargs)
1052 return self.handlers["auth"]
1054 def get_batch_handler(self, key, default=None, **kwargs):
1055 """
1056 Get the configured :term:`batch handler` for the given type.
1058 :param key: Unique key designating the :term:`batch type`.
1060 :param default: Spec string to use as the default, if none is
1061 configured.
1063 :returns: :class:`~wuttjamaican.batch.BatchHandler` instance
1064 for the requested type. If no spec can be determined, a
1065 ``KeyError`` is raised.
1066 """
1067 spec = self.config.get(
1068 f"{self.appname}.batch.{key}.handler.spec", default=default
1069 )
1070 if not spec:
1071 spec = self.config.get(f"{self.appname}.batch.{key}.handler.default_spec")
1072 if not spec:
1073 raise KeyError(f"handler spec not found for batch key: {key}")
1074 factory = self.load_object(spec)
1075 return factory(self.config, **kwargs)
1077 def get_batch_handler_specs(self, key, default=None):
1078 """
1079 Get the :term:`spec` strings for all available handlers of the
1080 given batch type.
1082 :param key: Unique key designating the :term:`batch type`.
1084 :param default: Default spec string(s) to include, even if not
1085 registered. Can be a string or list of strings.
1087 :returns: List of batch handler spec strings.
1089 This will gather available spec strings from the following:
1091 First, the ``default`` as provided by caller.
1093 Second, the default spec from config, if set; for example:
1095 .. code-block:: ini
1097 [wutta.batch]
1098 inventory.handler.default_spec = poser.batch.inventory:InventoryBatchHandler
1100 Third, each spec registered via entry points. For instance in
1101 ``pyproject.toml``:
1103 .. code-block:: toml
1105 [project.entry-points."wutta.batch.inventory"]
1106 poser = "poser.batch.inventory:InventoryBatchHandler"
1108 The final list will be "sorted" according to the above, with
1109 the latter registered handlers being sorted alphabetically.
1110 """
1111 handlers = []
1113 # defaults from caller
1114 if isinstance(default, str):
1115 handlers.append(default)
1116 elif default:
1117 handlers.extend(default)
1119 # configured default, if applicable
1120 default = self.config.get(
1121 f"{self.config.appname}.batch.{key}.handler.default_spec"
1122 )
1123 if default and default not in handlers:
1124 handlers.append(default)
1126 # registered via entry points
1127 registered = []
1128 for handler in load_entry_points(f"{self.appname}.batch.{key}").values():
1129 spec = handler.get_spec()
1130 if spec not in handlers:
1131 registered.append(spec)
1132 if registered:
1133 registered.sort()
1134 handlers.extend(registered)
1136 return handlers
1138 def get_db_handler(self, **kwargs):
1139 """
1140 Get the configured :term:`db handler`.
1142 :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler`
1143 """
1144 if "db" not in self.handlers:
1145 spec = self.config.get(
1146 f"{self.appname}.db.handler", default=self.default_db_handler_spec
1147 )
1148 factory = self.load_object(spec)
1149 self.handlers["db"] = factory(self.config, **kwargs)
1150 return self.handlers["db"]
1152 def get_email_handler(self, **kwargs):
1153 """
1154 Get the configured :term:`email handler`.
1156 See also :meth:`send_email()`.
1158 :rtype: :class:`~wuttjamaican.email.EmailHandler`
1159 """
1160 if "email" not in self.handlers:
1161 spec = self.config.get(
1162 f"{self.appname}.email.handler", default=self.default_email_handler_spec
1163 )
1164 factory = self.load_object(spec)
1165 self.handlers["email"] = factory(self.config, **kwargs)
1166 return self.handlers["email"]
1168 def get_install_handler(self, **kwargs):
1169 """
1170 Get the configured :term:`install handler`.
1172 :rtype: :class:`~wuttjamaican.install.handler.InstallHandler`
1173 """
1174 if "install" not in self.handlers:
1175 spec = self.config.get(
1176 f"{self.appname}.install.handler",
1177 default=self.default_install_handler_spec,
1178 )
1179 factory = self.load_object(spec)
1180 self.handlers["install"] = factory(self.config, **kwargs)
1181 return self.handlers["install"]
1183 def get_people_handler(self, **kwargs):
1184 """
1185 Get the configured "people" :term:`handler`.
1187 :rtype: :class:`~wuttjamaican.people.PeopleHandler`
1188 """
1189 if "people" not in self.handlers:
1190 spec = self.config.get(
1191 f"{self.appname}.people.handler",
1192 default=self.default_people_handler_spec,
1193 )
1194 factory = self.load_object(spec)
1195 self.handlers["people"] = factory(self.config, **kwargs)
1196 return self.handlers["people"]
1198 def get_problem_handler(self, **kwargs):
1199 """
1200 Get the configured :term:`problem handler`.
1202 :rtype: :class:`~wuttjamaican.problems.ProblemHandler`
1203 """
1204 if "problems" not in self.handlers:
1205 spec = self.config.get(
1206 f"{self.appname}.problems.handler",
1207 default=self.default_problem_handler_spec,
1208 )
1209 log.debug("problem_handler spec is: %s", spec)
1210 factory = self.load_object(spec)
1211 self.handlers["problems"] = factory(self.config, **kwargs)
1212 return self.handlers["problems"]
1214 def get_report_handler(self, **kwargs):
1215 """
1216 Get the configured :term:`report handler`.
1218 :rtype: :class:`~wuttjamaican.reports.ReportHandler`
1219 """
1220 if "reports" not in self.handlers:
1221 spec = self.config.get(
1222 f"{self.appname}.reports.handler_spec",
1223 default=self.default_report_handler_spec,
1224 )
1225 factory = self.load_object(spec)
1226 self.handlers["reports"] = factory(self.config, **kwargs)
1227 return self.handlers["reports"]
1229 ##############################
1230 # convenience delegators
1231 ##############################
1233 def get_person(self, obj, **kwargs):
1234 """
1235 Convenience method to locate a
1236 :class:`~wuttjamaican.db.model.base.Person` for the given
1237 object.
1239 This delegates to the "people" handler method,
1240 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
1241 """
1242 return self.get_people_handler().get_person(obj, **kwargs)
1244 def send_email(self, *args, **kwargs):
1245 """
1246 Send an email message.
1248 This is a convenience wrapper around
1249 :meth:`~wuttjamaican.email.EmailHandler.send_email()`.
1250 """
1251 self.get_email_handler().send_email(*args, **kwargs)
1254class AppProvider: # pylint: disable=too-few-public-methods
1255 """
1256 Base class for :term:`app providers<app provider>`.
1258 These can add arbitrary extra functionality to the main :term:`app
1259 handler`. See also :doc:`/narr/providers/app`.
1261 :param config: The app :term:`config object`.
1263 ``AppProvider`` instances have the following attributes:
1265 .. attribute:: config
1267 Reference to the config object.
1269 .. attribute:: app
1271 Reference to the parent app handler.
1273 Some things which a subclass may define, in order to register
1274 various features with the app:
1276 .. attribute:: email_modules
1278 List of :term:`email modules <email module>` provided. Should
1279 be a list of strings; each is a dotted module path, e.g.::
1281 email_modules = ['poser.emails']
1283 .. attribute:: email_templates
1285 List of :term:`email template` folders provided. Can be a list
1286 of paths, or a single path as string::
1288 email_templates = ['poser:templates/email']
1290 email_templates = 'poser:templates/email'
1292 Note the syntax, which specifies python module, then colon
1293 (``:``), then filesystem path below that. However absolute
1294 file paths may be used as well, when applicable.
1295 """
1297 def __init__(self, config):
1298 if isinstance(config, AppHandler):
1299 warnings.warn(
1300 "passing app handler to app provider is deprecated; "
1301 "must pass config object instead",
1302 DeprecationWarning,
1303 stacklevel=2,
1304 )
1305 config = config.config
1307 self.config = config
1308 self.app = self.config.get_app()
1310 @property
1311 def appname(self):
1312 """
1313 The :term:`app name` for the current app.
1315 See also :attr:`AppHandler.appname`.
1316 """
1317 return self.app.appname
1320class GenericHandler:
1321 """
1322 Generic base class for handlers.
1324 When the :term:`app` defines a new *type* of :term:`handler` it
1325 may subclass this when defining the handler base class.
1327 :param config: Config object for the app. This should be an
1328 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
1329 """
1331 def __init__(self, config):
1332 self.config = config
1333 self.app = self.config.get_app()
1334 self.modules = {}
1335 self.classes = {}
1337 @property
1338 def appname(self):
1339 """
1340 The :term:`app name` for the current app.
1342 See also :attr:`AppHandler.appname`.
1343 """
1344 return self.app.appname
1346 @classmethod
1347 def get_spec(cls):
1348 """
1349 Returns the class :term:`spec` string for the handler.
1350 """
1351 return f"{cls.__module__}:{cls.__name__}"
1353 def get_provider_modules(self, module_type):
1354 """
1355 Returns a list of all available modules of the given type.
1357 Not all handlers would need such a thing, but notable ones
1358 which do are the :term:`email handler` and :term:`report
1359 handler`. Both can obtain classes (emails or reports) from
1360 arbitrary modules, and this method is used to locate them.
1362 This will discover all modules exposed by the app
1363 :term:`providers <provider>`, which expose an attribute with
1364 name like ``f"{module_type}_modules"``.
1366 :param module_type: Unique name referring to a particular
1367 "type" of modules to locate, e.g. ``'email'``.
1369 :returns: List of module objects.
1370 """
1371 if module_type not in self.modules:
1372 self.modules[module_type] = []
1373 for provider in self.app.providers.values():
1374 name = f"{module_type}_modules"
1375 if hasattr(provider, name):
1376 modules = getattr(provider, name)
1377 if modules:
1378 if isinstance(modules, str):
1379 modules = [modules]
1380 for modpath in modules:
1381 module = importlib.import_module(modpath)
1382 self.modules[module_type].append(module)
1383 return self.modules[module_type]