Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/app.py: 100%
186 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-27 21:08 -0500
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-27 21:08 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24WuttJamaican - app handler
25"""
27import importlib
28import os
29import sys
30import warnings
32from wuttjamaican.util import (load_entry_points, load_object,
33 make_title, make_uuid, parse_bool,
34 progress_loop)
37class AppHandler:
38 """
39 Base class and default implementation for top-level :term:`app
40 handler`.
42 aka. "the handler to handle all handlers"
44 aka. "one handler to bind them all"
46 For more info see :doc:`/narr/handlers/app`.
48 There is normally no need to create one of these yourself; rather
49 you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()`
50 on the :term:`config object` if you need the app handler.
52 :param config: Config object for the app. This should be an
53 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
55 .. attribute:: model
57 Reference to the :term:`app model` module.
59 Note that :meth:`get_model()` is responsible for determining
60 which module this will point to. However you can always get
61 the model using this attribute (e.g. ``app.model``) and do not
62 need to call :meth:`get_model()` yourself - that part will
63 happen automatically.
65 .. attribute:: enum
67 Reference to the :term:`app enum` module.
69 Note that :meth:`get_enum()` is responsible for determining
70 which module this will point to. However you can always get
71 the model using this attribute (e.g. ``app.enum``) and do not
72 need to call :meth:`get_enum()` yourself - that part will
73 happen automatically.
75 .. attribute:: providers
77 Dictionary of :class:`AppProvider` instances, as returned by
78 :meth:`get_all_providers()`.
79 """
80 default_app_title = "WuttJamaican"
81 default_model_spec = 'wuttjamaican.db.model'
82 default_enum_spec = 'wuttjamaican.enum'
83 default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
84 default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
85 default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
87 def __init__(self, config):
88 self.config = config
89 self.handlers = {}
91 @property
92 def appname(self):
93 """
94 The :term:`app name` for the current app. This is just an
95 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`.
97 Note that this ``appname`` does not necessariy reflect what
98 you think of as the name of your (e.g. custom) app. It is
99 more fundamental than that; your Python package naming and the
100 :term:`app title` are free to use a different name as their
101 basis.
102 """
103 return self.config.appname
105 def __getattr__(self, name):
106 """
107 Custom attribute getter, called when the app handler does not
108 already have an attribute with the given ``name``.
110 This will delegate to the set of :term:`app providers<app
111 provider>`; the first provider with an appropriately-named
112 attribute wins, and that value is returned.
114 :returns: The first value found among the set of app
115 providers.
116 """
118 if name == 'model':
119 return self.get_model()
121 if name == 'enum':
122 return self.get_enum()
124 if name == 'providers':
125 self.providers = self.get_all_providers()
126 return self.providers
128 for provider in self.providers.values():
129 if hasattr(provider, name):
130 return getattr(provider, name)
132 raise AttributeError(f"attr not found: {name}")
134 def get_all_providers(self):
135 """
136 Load and return all registered providers.
138 Note that you do not need to call this directly; instead just
139 use :attr:`providers`.
141 The discovery logic is based on :term:`entry points<entry
142 point>` using the ``wutta.app.providers`` group. For instance
143 here is a sample entry point used by WuttaWeb (in its
144 ``pyproject.toml``):
146 .. code-block:: toml
148 [project.entry-points."wutta.app.providers"]
149 wuttaweb = "wuttaweb.app:WebAppProvider"
151 :returns: Dictionary keyed by entry point name; values are
152 :class:`AppProvider` instances.
153 """
154 # nb. must use 'wutta' and not self.appname prefix here, or
155 # else we can't find all providers with custom appname
156 providers = load_entry_points('wutta.app.providers')
157 for key in list(providers):
158 providers[key] = providers[key](self.config)
159 return providers
161 def get_title(self, default=None):
162 """
163 Returns the configured title for the app.
165 :param default: Value to be returned if there is no app title
166 configured.
168 :returns: Title for the app.
169 """
170 return self.config.get(f'{self.appname}.app_title',
171 default=default or self.default_app_title)
173 def get_node_title(self, default=None):
174 """
175 Returns the configured title for the local app node.
177 If none is configured, and no default provided, will return
178 the value from :meth:`get_title()`.
180 :param default: Value to use if the node title is not
181 configured.
183 :returns: Title for the local app node.
184 """
185 title = self.config.get(f'{self.appname}.node_title')
186 if title:
187 return title
188 return self.get_title(default=default)
190 def get_node_type(self, default=None):
191 """
192 Returns the "type" of current app node.
194 The framework itself does not (yet?) have any notion of what a
195 node type means. This abstraction is here for convenience, in
196 case it is needed by a particular app ecosystem.
198 :returns: String name for the node type, or ``None``.
200 The node type must be configured via file; this cannot be done
201 with a DB setting. Depending on :attr:`appname` that is like
202 so:
204 .. code-block:: ini
206 [wutta]
207 node_type = warehouse
208 """
209 return self.config.get(f'{self.appname}.node_type', default=default,
210 usedb=False)
212 def get_distribution(self, obj=None):
213 """
214 Returns the appropriate Python distribution name.
216 If ``obj`` is specified, this will attempt to locate the
217 distribution based on the top-level module which contains the
218 object's type/class.
220 If ``obj`` is *not* specified, this behaves a bit differently.
221 It first will look for a :term:`config setting` named
222 ``wutta.app_dist`` (or similar, dpending on :attr:`appname`).
223 If there is such a config value, it is returned. Otherwise
224 the "auto-locate" logic described above happens, but using
225 ``self`` instead of ``obj``.
227 In other words by default this returns the distribution to
228 which the running :term:`app handler` belongs.
230 See also :meth:`get_version()`.
232 :param obj: Any object which may be used as a clue to locate
233 the appropriate distribution.
235 :returns: string, or ``None``
237 Also note that a *distribution* name is different from a
238 *package* name. The distribution name is how things appear on
239 PyPI for instance.
241 If you want to override the default distribution name (and
242 skip the auto-locate based on app handler) then you can define
243 it in config:
245 .. code-block:: ini
247 [wutta]
248 app_dist = My-Poser-Dist
249 """
250 if obj is None:
251 dist = self.config.get(f'{self.appname}.app_dist')
252 if dist:
253 return dist
255 # TODO: do we need a config setting for app_package ?
256 #modpath = self.config.get(f'{self.appname}.app_package')
257 modpath = None
258 if not modpath:
259 modpath = type(obj if obj is not None else self).__module__
260 pkgname = modpath.split('.')[0]
262 try:
263 from importlib.metadata import packages_distributions
264 except ImportError: # python < 3.10
265 from importlib_metadata import packages_distributions
267 pkgmap = packages_distributions()
268 if pkgname in pkgmap:
269 dist = pkgmap[pkgname][0]
270 return dist
272 # fall back to configured dist, if obj lookup failed
273 if obj is not None:
274 return self.config.get(f'{self.appname}.app_dist')
276 def get_version(self, dist=None, obj=None):
277 """
278 Returns the version of a given Python distribution.
280 If ``dist`` is not specified, calls :meth:`get_distribution()`
281 to get it. (It passes ``obj`` along for this).
283 So by default this will return the version of whichever
284 distribution owns the running :term:`app handler`.
286 :returns: Version as string.
287 """
288 from importlib.metadata import version
290 if not dist:
291 dist = self.get_distribution(obj=obj)
292 if dist:
293 return version(dist)
295 def get_model(self):
296 """
297 Returns the :term:`app model` module.
299 Note that you don't actually need to call this method; you can
300 get the model by simply accessing :attr:`model`
301 (e.g. ``app.model``) instead.
303 By default this will return :mod:`wuttjamaican.db.model`
304 unless the config class or some :term:`config extension` has
305 provided another default.
307 A custom app can override the default like so (within a config
308 extension)::
310 config.setdefault('wutta.model_spec', 'poser.db.model')
311 """
312 if 'model' not in self.__dict__:
313 spec = self.config.get(f'{self.appname}.model_spec',
314 usedb=False,
315 default=self.default_model_spec)
316 self.model = importlib.import_module(spec)
317 return self.model
319 def get_enum(self):
320 """
321 Returns the :term:`app enum` module.
323 Note that you don't actually need to call this method; you can
324 get the module by simply accessing :attr:`enum`
325 (e.g. ``app.enum``) instead.
327 By default this will return :mod:`wuttjamaican.enum` unless
328 the config class or some :term:`config extension` has provided
329 another default.
331 A custom app can override the default like so (within a config
332 extension)::
334 config.setdefault('wutta.enum_spec', 'poser.enum')
335 """
336 if 'enum' not in self.__dict__:
337 spec = self.config.get(f'{self.appname}.enum_spec',
338 usedb=False,
339 default=self.default_enum_spec)
340 self.enum = importlib.import_module(spec)
341 return self.enum
343 def load_object(self, spec):
344 """
345 Import and/or load and return the object designated by the
346 given spec string.
348 This invokes :func:`wuttjamaican.util.load_object()`.
350 :param spec: String of the form ``module.dotted.path:objname``.
352 :returns: The object referred to by ``spec``. If the module
353 could not be imported, or did not contain an object of the
354 given name, then an error will raise.
355 """
356 return load_object(spec)
358 def get_appdir(self, *args, **kwargs):
359 """
360 Returns path to the :term:`app dir`.
362 This does not check for existence of the path, it only reads
363 it from config or (optionally) provides a default path.
365 :param configured_only: Pass ``True`` here if you only want
366 the configured path and ignore the default path.
368 :param create: Pass ``True`` here if you want to ensure the
369 returned path exists, creating it if necessary.
371 :param \*args: Any additional args will be added as child
372 paths for the final value.
374 For instance, assuming ``/srv/envs/poser`` is the virtual
375 environment root::
377 app.get_appdir() # => /srv/envs/poser/app
379 app.get_appdir('data') # => /srv/envs/poser/app/data
380 """
381 configured_only = kwargs.pop('configured_only', False)
382 create = kwargs.pop('create', False)
384 # maybe specify default path
385 if not configured_only:
386 path = os.path.join(sys.prefix, 'app')
387 kwargs.setdefault('default', path)
389 # get configured path
390 kwargs.setdefault('usedb', False)
391 path = self.config.get(f'{self.appname}.appdir', **kwargs)
393 # add any subpath info
394 if path and args:
395 path = os.path.join(path, *args)
397 # create path if requested/needed
398 if create:
399 if not path:
400 raise ValueError("appdir path unknown! so cannot create it.")
401 if not os.path.exists(path):
402 os.makedirs(path)
404 return path
406 def make_appdir(self, path, subfolders=None, **kwargs):
407 """
408 Establish an :term:`app dir` at the given path.
410 Default logic only creates a few subfolders, meant to help
411 steer the admin toward a convention for sake of where to put
412 things. But custom app handlers are free to do whatever.
414 :param path: Path to the desired app dir. If the path does
415 not yet exist then it will be created. But regardless it
416 should be "refreshed" (e.g. missing subfolders created)
417 when this method is called.
419 :param subfolders: Optional list of subfolder names to create
420 within the app dir. If not specified, defaults will be:
421 ``['data', 'log', 'work']``.
422 """
423 appdir = path
424 if not os.path.exists(appdir):
425 os.makedirs(appdir)
427 if not subfolders:
428 subfolders = ['data', 'log', 'work']
430 for name in subfolders:
431 path = os.path.join(appdir, name)
432 if not os.path.exists(path):
433 os.mkdir(path)
435 def make_session(self, **kwargs):
436 """
437 Creates a new SQLAlchemy session for the app DB. By default
438 this will create a new :class:`~wuttjamaican.db.sess.Session`
439 instance.
441 :returns: SQLAlchemy session for the app DB.
442 """
443 from .db import Session
445 return Session(**kwargs)
447 def make_title(self, text, **kwargs):
448 """
449 Return a human-friendly "title" for the given text.
451 This is mostly useful for converting a Python variable name (or
452 similar) to a human-friendly string, e.g.::
454 make_title('foo_bar') # => 'Foo Bar'
456 By default this just invokes
457 :func:`wuttjamaican.util.make_title()`.
458 """
459 return make_title(text)
461 def make_uuid(self):
462 """
463 Generate a new UUID value.
465 By default this simply calls
466 :func:`wuttjamaican.util.make_uuid()`.
468 :returns: UUID value as 32-character string.
469 """
470 return make_uuid()
472 def progress_loop(self, *args, **kwargs):
473 """
474 Convenience method to iterate over a set of items, invoking
475 logic for each, and updating a progress indicator along the
476 way.
478 This is a wrapper around
479 :func:`wuttjamaican.util.progress_loop()`; see those docs for
480 param details.
481 """
482 return progress_loop(*args, **kwargs)
484 def get_session(self, obj):
485 """
486 Returns the SQLAlchemy session with which the given object is
487 associated. Simple convenience wrapper around
488 :func:`sqlalchemy:sqlalchemy.orm.object_session()`.
489 """
490 from sqlalchemy import orm
492 return orm.object_session(obj)
494 def short_session(self, **kwargs):
495 """
496 Returns a context manager for a short-lived database session.
498 This is a convenience wrapper around
499 :class:`~wuttjamaican.db.sess.short_session`.
501 If caller does not specify ``factory`` nor ``config`` params,
502 this method will provide a default factory in the form of
503 :meth:`make_session`.
504 """
505 from .db import short_session
507 if 'factory' not in kwargs and 'config' not in kwargs:
508 kwargs['factory'] = self.make_session
510 return short_session(**kwargs)
512 def get_setting(self, session, name, **kwargs):
513 """
514 Get a :term:`config setting` value from the DB.
516 This does *not* consult the :term:`config object` directly to
517 determine the setting value; it always queries the DB.
519 Default implementation is just a convenience wrapper around
520 :func:`~wuttjamaican.db.conf.get_setting()`.
522 See also :meth:`save_setting()` and :meth:`delete_setting()`.
524 :param session: App DB session.
526 :param name: Name of the setting to get.
528 :returns: Setting value as string, or ``None``.
529 """
530 from .db import get_setting
532 return get_setting(session, name)
534 def save_setting(
535 self,
536 session,
537 name,
538 value,
539 force_create=False,
540 ):
541 """
542 Save a :term:`config setting` value to the DB.
544 See also :meth:`get_setting()` and :meth:`delete_setting()`.
546 :param session: Current :term:`db session`.
548 :param name: Name of the setting to save.
550 :param value: Value to be saved for the setting; should be
551 either a string or ``None``.
553 :param force_create: If ``False`` (the default) then logic
554 will first try to locate an existing setting of the same
555 name, and update it if found, or create if not.
557 But if this param is ``True`` then logic will only try to
558 create a new record, and not bother checking to see if it
559 exists.
561 (Theoretically the latter offers a slight efficiency gain.)
562 """
563 model = self.model
565 # maybe fetch existing setting
566 setting = None
567 if not force_create:
568 setting = session.get(model.Setting, name)
570 # create setting if needed
571 if not setting:
572 setting = model.Setting(name=name)
573 session.add(setting)
575 # set value
576 setting.value = value
578 def delete_setting(self, session, name):
579 """
580 Delete a :term:`config setting` from the DB.
582 See also :meth:`get_setting()` and :meth:`save_setting()`.
584 :param session: Current :term:`db session`.
586 :param name: Name of the setting to delete.
587 """
588 model = self.model
589 setting = session.get(model.Setting, name)
590 if setting:
591 session.delete(setting)
593 def continuum_is_enabled(self):
594 """
595 Returns boolean indicating if Wutta-Continuum is installed and
596 enabled.
598 Default will be ``False`` as enabling it requires additional
599 installation and setup. For instructions see
600 :doc:`wutta-continuum:narr/install`.
601 """
602 for provider in self.providers.values():
603 if hasattr(provider, 'continuum_is_enabled'):
604 return provider.continuum_is_enabled()
606 return False
608 ##############################
609 # getters for other handlers
610 ##############################
612 def get_auth_handler(self, **kwargs):
613 """
614 Get the configured :term:`auth handler`.
616 :rtype: :class:`~wuttjamaican.auth.AuthHandler`
617 """
618 if 'auth' not in self.handlers:
619 spec = self.config.get(f'{self.appname}.auth.handler',
620 default=self.default_auth_handler_spec)
621 factory = self.load_object(spec)
622 self.handlers['auth'] = factory(self.config, **kwargs)
623 return self.handlers['auth']
625 def get_email_handler(self, **kwargs):
626 """
627 Get the configured :term:`email handler`.
629 See also :meth:`send_email()`.
631 :rtype: :class:`~wuttjamaican.email.handler.EmailHandler`
632 """
633 if 'email' not in self.handlers:
634 spec = self.config.get(f'{self.appname}.email.handler',
635 default=self.default_email_handler_spec)
636 factory = self.load_object(spec)
637 self.handlers['email'] = factory(self.config, **kwargs)
638 return self.handlers['email']
640 def get_people_handler(self, **kwargs):
641 """
642 Get the configured "people" :term:`handler`.
644 :rtype: :class:`~wuttjamaican.people.PeopleHandler`
645 """
646 if 'people' not in self.handlers:
647 spec = self.config.get(f'{self.appname}.people.handler',
648 default=self.default_people_handler_spec)
649 factory = self.load_object(spec)
650 self.handlers['people'] = factory(self.config, **kwargs)
651 return self.handlers['people']
653 ##############################
654 # convenience delegators
655 ##############################
657 def get_person(self, obj, **kwargs):
658 """
659 Convenience method to locate a
660 :class:`~wuttjamaican.db.model.base.Person` for the given
661 object.
663 This delegates to the "people" handler method,
664 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
665 """
666 return self.get_people_handler().get_person(obj, **kwargs)
668 def send_email(self, *args, **kwargs):
669 """
670 Send an email message.
672 This is a convenience wrapper around
673 :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`.
674 """
675 self.get_email_handler().send_email(*args, **kwargs)
678class AppProvider:
679 """
680 Base class for :term:`app providers<app provider>`.
682 These can add arbitrary extra functionality to the main :term:`app
683 handler`. See also :doc:`/narr/providers/app`.
685 :param config: The app :term:`config object`.
687 Instances have the following attributes:
689 .. attribute:: config
691 Reference to the config object.
693 .. attribute:: app
695 Reference to the parent app handler.
696 """
698 def __init__(self, config):
700 if isinstance(config, AppHandler):
701 warnings.warn("passing app handler to app provider is deprecated; "
702 "must pass config object instead",
703 DeprecationWarning, stacklevel=2)
704 config = config.config
706 self.config = config
707 self.app = self.config.get_app()
709 @property
710 def appname(self):
711 """
712 The :term:`app name` for the current app.
714 See also :attr:`AppHandler.appname`.
715 """
716 return self.app.appname
719class GenericHandler:
720 """
721 Generic base class for handlers.
723 When the :term:`app` defines a new *type* of :term:`handler` it
724 may subclass this when defining the handler base class.
726 :param config: Config object for the app. This should be an
727 instance of :class:`~wuttjamaican.conf.WuttaConfig`.
728 """
730 def __init__(self, config, **kwargs):
731 self.config = config
732 self.app = self.config.get_app()
734 @property
735 def appname(self):
736 """
737 The :term:`app name` for the current app.
739 See also :attr:`AppHandler.appname`.
740 """
741 return self.app.appname