Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/util.py: 100%
160 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-03-21 11:56 -0500
« prev ^ index » next coverage.py v7.11.0, created at 2026-03-21 11:56 -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 - utilities
25"""
27import datetime
28import importlib
29import logging
30import os
31import shlex
32import warnings
34from uuid_extensions import uuid7
37log = logging.getLogger(__name__)
40# nb. this is used as default kwarg value in some places, to
41# distinguish passing a ``None`` value, vs. *no* value at all
42UNSPECIFIED = object()
45def get_class_hierarchy(klass, topfirst=True):
46 """
47 Returns a list of all classes in the inheritance chain for the
48 given class.
50 For instance::
52 class A:
53 pass
55 class B(A):
56 pass
58 class C(B):
59 pass
61 get_class_hierarchy(C)
62 # -> [A, B, C]
64 :param klass: The reference class. The list of classes returned
65 will include this class and all its parents.
67 :param topfirst: Whether the returned list should be sorted in a
68 "top first" way, e.g. A) grandparent, B) parent, C) child.
69 This is the default but pass ``False`` to get the reverse.
70 """
71 hierarchy = []
73 def traverse(cls):
74 if cls is not object:
75 hierarchy.append(cls)
76 for parent in cls.__bases__:
77 traverse(parent)
79 traverse(klass)
80 if topfirst:
81 hierarchy.reverse()
82 return hierarchy
85def get_value(obj, key):
86 """
87 Convenience function to retrive a value by name from the given
88 object. This will first try to assume the object is a dict but
89 will fallback to using ``getattr()`` on it.
91 :param obj: Arbitrary dict or object of any kind which would have
92 named attributes.
94 :param key: Key/name of the field to get.
96 :returns: Whatever value is found. Or maybe an ``AttributeError``
97 is raised if the object does not have the key/attr set.
98 """
99 # nb. we try dict access first, since wutta data model objects
100 # should all support that anyway, so it's 2 birds 1 stone.
101 try:
102 return obj[key]
104 except (KeyError, TypeError):
105 # nb. key error means the object supports key lookup (i.e. is
106 # dict-like) but did not have that key set. which is actually
107 # an expected scenario for association proxy fields, but for
108 # those a getattr() should still work; see also
109 # wuttjamaican.db.util.ModelBase
110 return getattr(obj, key)
113def load_entry_points(group, lists=False, ignore_errors=False):
114 """
115 Load a set of ``setuptools``-style :term:`entry points <entry
116 point>`.
118 This is used to locate "plugins" and similar things, e.g. discover
119 which batch handlers are installed.
121 Logic will inspect the registered entry points and return a dict
122 whose keys are the entry point names. By default the dict values
123 will be the loaded objects as referenced by each entry point.
125 In some cases (notably, import handlers for wuttasync) the keys
126 may not always be unique. This allows multiple projects to define
127 entry points for the same key. If you specify ``lists=True`` then
128 the dict values will each be *lists* of loaded objects instead.
129 (Otherwise some entry points would be discarded when duplicate
130 keys are found.)
132 :param group: The group (string name) of entry points to be
133 loaded, e.g. ``'wutta.commands'``.
135 :param lists: Whether to return lists instead of single object
136 values.
138 :param ignore_errors: If false (the default), any errors will be
139 raised normally. If true, errors will be logged but not
140 raised.
142 :returns: A dict of entry points, as described above.
143 """
144 entry_points = {}
146 try:
147 # nb. this package was added in python 3.8
148 import importlib.metadata as importlib_metadata # pylint: disable=import-outside-toplevel
149 except ImportError:
150 import importlib_metadata # pylint: disable=import-outside-toplevel
152 eps = importlib_metadata.entry_points()
153 if not hasattr(eps, "select"):
154 # python < 3.10
155 eps = eps.get(group, [])
156 else:
157 # python >= 3.10
158 eps = eps.select(group=group)
159 for entry_point in eps:
160 try:
161 ep = entry_point.load()
162 except Exception: # pylint: disable=broad-exception-caught
163 if not ignore_errors:
164 raise
165 log.warning("failed to load entry point: %s", entry_point, exc_info=True)
166 else:
167 if lists:
168 entry_points.setdefault(entry_point.name, []).append(ep)
169 else:
170 if entry_point.name in entry_points:
171 # TODO: not sure why the "same" entry point can be
172 # discovered multiple times, but in practice i did
173 # see this. however it was on a python 3.8 system
174 # so i'm guessing that has something to do with
175 # it. we'll avoid the warning if that's the case.
176 if entry_points[entry_point.name] is not ep:
177 log.warning(
178 "overwriting existing key '%s' with entry point: %s",
179 entry_point.name,
180 ep,
181 )
182 entry_points[entry_point.name] = ep
184 return entry_points
187def load_object(spec):
188 """
189 Load an arbitrary object from a module, according to the spec.
191 The spec string should contain a dotted path to an importable module,
192 followed by a colon (``':'``), followed by the name of the object to be
193 loaded. For example:
195 .. code-block:: none
197 wuttjamaican.util:parse_bool
199 You'll notice from this example that "object" in this context refers to any
200 valid Python object, i.e. not necessarily a class instance. The name may
201 refer to a class, function, variable etc. Once the module is imported, the
202 ``getattr()`` function is used to obtain a reference to the named object;
203 therefore anything supported by that approach should work.
205 :param spec: Spec string.
207 :returns: The specified object.
208 """
209 if not spec:
210 raise ValueError("no object spec provided")
212 module_path, name = spec.split(":")
213 module = importlib.import_module(module_path)
214 return getattr(module, name)
217def make_title(text):
218 """
219 Return a human-friendly "title" for the given text.
221 This is mostly useful for converting a Python variable name (or
222 similar) to a human-friendly string, e.g.::
224 make_title('foo_bar') # => 'Foo Bar'
225 """
226 text = text.replace("_", " ")
227 text = text.replace("-", " ")
228 words = text.split()
229 return " ".join([x.capitalize() for x in words])
232def make_full_name(*parts):
233 """
234 Make a "full name" from the given parts.
236 :param \\*parts: Distinct name values which should be joined
237 together to make the full name.
239 :returns: The full name.
241 For instance::
243 make_full_name('First', '', 'Last', 'Suffix')
244 # => "First Last Suffix"
245 """
246 parts = [(part or "").strip() for part in parts]
247 parts = [part for part in parts if part]
248 return " ".join(parts)
251def get_timezone_by_name(tzname):
252 """
253 Retrieve a timezone object by name.
255 This is mostly a compatibility wrapper, since older Python is
256 missing the :mod:`python:zoneinfo` module.
258 For Python 3.9 and newer, this instantiates
259 :class:`python:zoneinfo.ZoneInfo`.
261 For Python 3.8, this calls :func:`dateutil:dateutil.tz.gettz()`.
263 See also :meth:`~wuttjamaican.app.AppHandler.get_timezone()` on
264 the app handler.
266 :param tzname: String name for timezone.
268 :returns: :class:`python:datetime.tzinfo` instance
269 """
270 try:
271 from zoneinfo import ZoneInfo # pylint: disable=import-outside-toplevel
273 return ZoneInfo(tzname)
275 except ImportError: # python 3.8
276 from dateutil.tz import gettz # pylint: disable=import-outside-toplevel
278 return gettz(tzname)
281def localtime(dt=None, from_utc=True, want_tzinfo=True, local_zone=None):
282 """
283 This produces a datetime in the "local" timezone. By default it
284 will be *zone-aware*.
286 See also the shortcut
287 :meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app
288 handler. For usage examples see :ref:`convert-to-localtime`.
290 See also :func:`make_utc()` which is sort of the inverse.
292 :param dt: Optional :class:`python:datetime.datetime` instance.
293 If not specified, the current time will be used.
295 :param from_utc: Boolean indicating whether a naive ``dt`` is
296 already (effectively) in UTC timezone. Set this to false when
297 providing a naive ``dt`` which is already in "local" timezone
298 instead of UTC. This flag is ignored if ``dt`` is zone-aware.
300 :param want_tzinfo: Boolean indicating whether the resulting
301 datetime should have its
302 :attr:`~python:datetime.datetime.tzinfo` attribute set. Set
303 this to false if you want a naive value; it's true by default,
304 for zone-aware.
306 :param local_zone: Optional :class:`python:datetime.tzinfo`
307 instance to use as "local" timezone, instead of relying on
308 Python to determine the system local timezone.
310 :returns: :class:`python:datetime.datetime` instance in local
311 timezone.
312 """
313 # use current time if none provided
314 if dt is None:
315 dt = datetime.datetime.now(datetime.timezone.utc)
317 # set dt's timezone if needed
318 if not dt.tzinfo:
319 # UTC is default assumption unless caller says otherwise
320 if from_utc:
321 dt = dt.replace(tzinfo=datetime.timezone.utc)
322 elif local_zone:
323 dt = dt.replace(tzinfo=local_zone)
324 else: # default system local timezone
325 tz = dt.astimezone().tzinfo
326 dt = dt.replace(tzinfo=tz)
328 # convert to local timezone
329 if local_zone:
330 dt = dt.astimezone(local_zone)
331 else:
332 dt = dt.astimezone()
334 # maybe strip tzinfo
335 if want_tzinfo:
336 return dt
337 return dt.replace(tzinfo=None)
340def make_utc(dt=None, tzinfo=False):
341 """
342 This returns a datetime local to the UTC timezone. By default it
343 will be a *naive* datetime; the common use case is to convert as
344 needed for sake of writing to the database.
346 See also the shortcut
347 :meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app
348 handler. For usage examples see :ref:`convert-to-utc`.
350 See also :func:`localtime()` which is sort of the inverse.
352 :param dt: Optional :class:`python:datetime.datetime` instance.
353 If not specified, the current time will be used.
355 :param tzinfo: Boolean indicating whether the return value should
356 have its :attr:`~python:datetime.datetime.tzinfo` attribute
357 set. This is false by default in which case the return value
358 will be naive.
360 :returns: :class:`python:datetime.datetime` instance local to UTC.
361 """
362 # use current time if none provided
363 if dt is None:
364 now = datetime.datetime.now(datetime.timezone.utc)
365 if tzinfo:
366 return now
367 return now.replace(tzinfo=None)
369 # otherwise may need to convert timezone
370 if dt.tzinfo:
371 if dt.tzinfo is not datetime.timezone.utc:
372 dt = dt.astimezone(datetime.timezone.utc)
373 if tzinfo:
374 return dt
375 return dt.replace(tzinfo=None)
377 # naive value returned as-is..
378 if not tzinfo:
379 return dt
381 # ..unless tzinfo is wanted, in which case this assumes naive
382 # value is in the UTC timezone
383 return dt.replace(tzinfo=datetime.timezone.utc)
386# TODO: deprecate / remove this eventually
387def make_true_uuid():
388 """
389 Generate a new v7 UUID.
391 See also :func:`make_uuid()`.
393 :returns: :class:`python:uuid.UUID` instance
394 """
395 return uuid7()
398# TODO: deprecate / remove this eventually
399def make_str_uuid():
400 """
401 Generate a new v7 UUID value as string.
403 See also :func:`make_uuid()`.
405 :returns: UUID as 32-character hex string
406 """
407 return make_true_uuid().hex
410# TODO: eventually refactor, to return true uuid
411def make_uuid():
412 """
413 Generate a new v7 UUID value.
415 See also the app handler shortcut,
416 :meth:`~wuttjamaican.app.AppHandler.make_uuid()`.
418 :returns: UUID as 32-character hex string
420 .. warning::
422 **TEMPORARY BEHAVIOR**
424 For the moment, use of this function is discouraged. Instead you
425 should use :func:`make_true_uuid()` or :func:`make_str_uuid()` to
426 be explicit about the return type you expect.
428 *Eventually* (once it's clear most/all callers are using the
429 explicit functions) this will be refactored to return a UUID
430 instance. But for now this function returns a string.
431 """
432 warnings.warn(
433 "util.make_uuid() is temporarily deprecated, in favor of "
434 "explicit functions, util.make_true_uuid() and util.make_str_uuid()",
435 DeprecationWarning,
436 stacklevel=2,
437 )
438 return make_str_uuid()
441def parse_bool(value):
442 """
443 Derive a boolean from the given string value.
444 """
445 if value is None:
446 return None
447 if isinstance(value, bool):
448 return value
449 if str(value).lower() in ("true", "yes", "y", "on", "1"):
450 return True
451 return False
454def parse_list(value):
455 """
456 Parse a configuration value, splitting by whitespace and/or commas
457 and taking quoting into account etc., yielding a list of strings.
458 """
459 if value is None:
460 return []
461 if isinstance(value, list):
462 return value
463 parser = shlex.shlex(value)
464 parser.whitespace += ","
465 parser.whitespace_split = True
466 values = list(parser)
467 for i, val in enumerate(values):
468 if val.startswith('"') and val.endswith('"'):
469 values[i] = val[1:-1]
470 elif val.startswith("'") and val.endswith("'"):
471 values[i] = val[1:-1]
472 return values
475def progress_loop(func, items, factory, message=None):
476 """
477 Convenience function to iterate over a set of items, invoking
478 logic for each, and updating a progress indicator along the way.
480 This function may also be called via the :term:`app handler`; see
481 :meth:`~wuttjamaican.app.AppHandler.progress_loop()`.
483 The ``factory`` will be called to create the progress indicator,
484 which should be an instance of
485 :class:`~wuttjamaican.progress.ProgressBase`.
487 The ``factory`` may also be ``None`` in which case there is no
488 progress, and this is really just a simple "for loop".
490 :param func: Callable to be invoked for each item in the sequence.
491 See below for more details.
493 :param items: Sequence of items over which to iterate.
495 :param factory: Callable which creates/returns a progress
496 indicator, or can be ``None`` for no progress.
498 :param message: Message to display along with the progress
499 indicator. If no message is specified, whether a default is
500 shown will be up to the progress indicator.
502 The ``func`` param should be a callable which accepts 2 positional
503 args ``(obj, i)`` - meaning for which is as follows:
505 :param obj: This will be an item within the sequence.
507 :param i: This will be the *one-based* sequence number for the
508 item.
510 See also :class:`~wuttjamaican.progress.ConsoleProgress` for a
511 usage example.
512 """
513 progress = None
514 if factory:
515 count = len(items)
516 progress = factory(message, count)
518 for i, item in enumerate(items, 1):
519 func(item, i)
520 if progress:
521 progress.update(i)
523 if progress:
524 progress.finish()
527def resource_path(path):
528 """
529 Returns the absolute file path for the given resource path.
531 A "resource path" is one which designates a python package name,
532 plus some path under that. For instance:
534 .. code-block:: none
536 wuttjamaican.email:templates
538 Assuming such a path should exist, the question is "where?"
540 So this function uses :mod:`python:importlib.resources` to locate
541 the path, possibly extracting the file(s) from a zipped package,
542 and returning the final path on disk.
544 It only does this if it detects it is needed, based on the given
545 ``path`` argument. If that is already an absolute path then it
546 will be returned as-is.
548 :param path: Either a package resource specifier as shown above,
549 or regular file path.
551 :returns: Absolute file path to the resource.
552 """
553 if not os.path.isabs(path) and ":" in path:
554 try:
555 # nb. these were added in python 3.9
556 from importlib.resources import ( # pylint: disable=import-outside-toplevel
557 files,
558 as_file,
559 )
560 except ImportError: # python < 3.9
561 from importlib_resources import ( # pylint: disable=import-outside-toplevel
562 files,
563 as_file,
564 )
566 package, filename = path.split(":")
567 ref = files(package) / filename
568 with as_file(ref) as p:
569 return str(p)
571 return path
574def simple_error(error):
575 """
576 Return a "simple" string for the given error. Result will look
577 like::
579 "ErrorClass: Description for the error"
581 However the logic checks to ensure the error has a descriptive
582 message first; if it doesn't the result will just be::
584 "ErrorClass"
585 """
586 cls = type(error).__name__
587 msg = str(error)
588 if msg:
589 return f"{cls}: {msg}"
590 return cls