Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/util.py: 100%
150 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-21 01:23 -0600
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-21 01:23 -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 - 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 load_entry_points(group, ignore_errors=False):
86 """
87 Load a set of ``setuptools``-style entry points.
89 This is used to locate "plugins" and similar things, e.g. the set
90 of subcommands which belong to a main command.
92 :param group: The group (string name) of entry points to be
93 loaded, e.g. ``'wutta.commands'``.
95 :param ignore_errors: If false (the default), any errors will be
96 raised normally. If true, errors will be logged but not
97 raised.
99 :returns: A dictionary whose keys are the entry point names, and
100 values are the loaded entry points.
101 """
102 entry_points = {}
104 try:
105 # nb. this package was added in python 3.8
106 import importlib.metadata as importlib_metadata # pylint: disable=import-outside-toplevel
107 except ImportError:
108 import importlib_metadata # pylint: disable=import-outside-toplevel
110 eps = importlib_metadata.entry_points()
111 if not hasattr(eps, "select"):
112 # python < 3.10
113 eps = eps.get(group, [])
114 else:
115 # python >= 3.10
116 eps = eps.select(group=group)
117 for entry_point in eps:
118 try:
119 ep = entry_point.load()
120 except Exception: # pylint: disable=broad-exception-caught
121 if not ignore_errors:
122 raise
123 log.warning("failed to load entry point: %s", entry_point, exc_info=True)
124 else:
125 entry_points[entry_point.name] = ep
127 return entry_points
130def load_object(spec):
131 """
132 Load an arbitrary object from a module, according to the spec.
134 The spec string should contain a dotted path to an importable module,
135 followed by a colon (``':'``), followed by the name of the object to be
136 loaded. For example:
138 .. code-block:: none
140 wuttjamaican.util:parse_bool
142 You'll notice from this example that "object" in this context refers to any
143 valid Python object, i.e. not necessarily a class instance. The name may
144 refer to a class, function, variable etc. Once the module is imported, the
145 ``getattr()`` function is used to obtain a reference to the named object;
146 therefore anything supported by that approach should work.
148 :param spec: Spec string.
150 :returns: The specified object.
151 """
152 if not spec:
153 raise ValueError("no object spec provided")
155 module_path, name = spec.split(":")
156 module = importlib.import_module(module_path)
157 return getattr(module, name)
160def make_title(text):
161 """
162 Return a human-friendly "title" for the given text.
164 This is mostly useful for converting a Python variable name (or
165 similar) to a human-friendly string, e.g.::
167 make_title('foo_bar') # => 'Foo Bar'
168 """
169 text = text.replace("_", " ")
170 text = text.replace("-", " ")
171 words = text.split()
172 return " ".join([x.capitalize() for x in words])
175def make_full_name(*parts):
176 """
177 Make a "full name" from the given parts.
179 :param \\*parts: Distinct name values which should be joined
180 together to make the full name.
182 :returns: The full name.
184 For instance::
186 make_full_name('First', '', 'Last', 'Suffix')
187 # => "First Last Suffix"
188 """
189 parts = [(part or "").strip() for part in parts]
190 parts = [part for part in parts if part]
191 return " ".join(parts)
194def get_timezone_by_name(tzname):
195 """
196 Retrieve a timezone object by name.
198 This is mostly a compatibility wrapper, since older Python is
199 missing the :mod:`python:zoneinfo` module.
201 For Python 3.9 and newer, this instantiates
202 :class:`python:zoneinfo.ZoneInfo`.
204 For Python 3.8, this calls :func:`dateutil:dateutil.tz.gettz()`.
206 See also :meth:`~wuttjamaican.app.AppHandler.get_timezone()` on
207 the app handler.
209 :param tzname: String name for timezone.
211 :returns: :class:`python:datetime.tzinfo` instance
212 """
213 try:
214 from zoneinfo import ZoneInfo # pylint: disable=import-outside-toplevel
216 return ZoneInfo(tzname)
218 except ImportError: # python 3.8
219 from dateutil.tz import gettz # pylint: disable=import-outside-toplevel
221 return gettz(tzname)
224def localtime(dt=None, from_utc=True, want_tzinfo=True, local_zone=None):
225 """
226 This produces a datetime in the "local" timezone. By default it
227 will be *zone-aware*.
229 See also the shortcut
230 :meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app
231 handler. For usage examples see :ref:`convert-to-localtime`.
233 See also :func:`make_utc()` which is sort of the inverse.
235 :param dt: Optional :class:`python:datetime.datetime` instance.
236 If not specified, the current time will be used.
238 :param from_utc: Boolean indicating whether a naive ``dt`` is
239 already (effectively) in UTC timezone. Set this to false when
240 providing a naive ``dt`` which is already in "local" timezone
241 instead of UTC. This flag is ignored if ``dt`` is zone-aware.
243 :param want_tzinfo: Boolean indicating whether the resulting
244 datetime should have its
245 :attr:`~python:datetime.datetime.tzinfo` attribute set. Set
246 this to false if you want a naive value; it's true by default,
247 for zone-aware.
249 :param local_zone: Optional :class:`python:datetime.tzinfo`
250 instance to use as "local" timezone, instead of relying on
251 Python to determine the system local timezone.
253 :returns: :class:`python:datetime.datetime` instance in local
254 timezone.
255 """
256 # use current time if none provided
257 if dt is None:
258 dt = datetime.datetime.now(datetime.timezone.utc)
260 # set dt's timezone if needed
261 if not dt.tzinfo:
262 # UTC is default assumption unless caller says otherwise
263 if from_utc:
264 dt = dt.replace(tzinfo=datetime.timezone.utc)
265 elif local_zone:
266 dt = dt.replace(tzinfo=local_zone)
267 else: # default system local timezone
268 tz = dt.astimezone().tzinfo
269 dt = dt.replace(tzinfo=tz)
271 # convert to local timezone
272 if local_zone:
273 dt = dt.astimezone(local_zone)
274 else:
275 dt = dt.astimezone()
277 # maybe strip tzinfo
278 if want_tzinfo:
279 return dt
280 return dt.replace(tzinfo=None)
283def make_utc(dt=None, tzinfo=False):
284 """
285 This returns a datetime local to the UTC timezone. By default it
286 will be a *naive* datetime; the common use case is to convert as
287 needed for sake of writing to the database.
289 See also the shortcut
290 :meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app
291 handler. For usage examples see :ref:`convert-to-utc`.
293 See also :func:`localtime()` which is sort of the inverse.
295 :param dt: Optional :class:`python:datetime.datetime` instance.
296 If not specified, the current time will be used.
298 :param tzinfo: Boolean indicating whether the return value should
299 have its :attr:`~python:datetime.datetime.tzinfo` attribute
300 set. This is false by default in which case the return value
301 will be naive.
303 :returns: :class:`python:datetime.datetime` instance local to UTC.
304 """
305 # use current time if none provided
306 if dt is None:
307 now = datetime.datetime.now(datetime.timezone.utc)
308 if tzinfo:
309 return now
310 return now.replace(tzinfo=None)
312 # otherwise may need to convert timezone
313 if dt.tzinfo:
314 if dt.tzinfo is not datetime.timezone.utc:
315 dt = dt.astimezone(datetime.timezone.utc)
316 if tzinfo:
317 return dt
318 return dt.replace(tzinfo=None)
320 # naive value returned as-is..
321 if not tzinfo:
322 return dt
324 # ..unless tzinfo is wanted, in which case this assumes naive
325 # value is in the UTC timezone
326 return dt.replace(tzinfo=datetime.timezone.utc)
329# TODO: deprecate / remove this eventually
330def make_true_uuid():
331 """
332 Generate a new v7 UUID.
334 See also :func:`make_uuid()`.
336 :returns: :class:`python:uuid.UUID` instance
337 """
338 return uuid7()
341# TODO: deprecate / remove this eventually
342def make_str_uuid():
343 """
344 Generate a new v7 UUID value as string.
346 See also :func:`make_uuid()`.
348 :returns: UUID as 32-character hex string
349 """
350 return make_true_uuid().hex
353# TODO: eventually refactor, to return true uuid
354def make_uuid():
355 """
356 Generate a new v7 UUID value.
358 See also the app handler shortcut,
359 :meth:`~wuttjamaican.app.AppHandler.make_uuid()`.
361 :returns: UUID as 32-character hex string
363 .. warning::
365 **TEMPORARY BEHAVIOR**
367 For the moment, use of this function is discouraged. Instead you
368 should use :func:`make_true_uuid()` or :func:`make_str_uuid()` to
369 be explicit about the return type you expect.
371 *Eventually* (once it's clear most/all callers are using the
372 explicit functions) this will be refactored to return a UUID
373 instance. But for now this function returns a string.
374 """
375 warnings.warn(
376 "util.make_uuid() is temporarily deprecated, in favor of "
377 "explicit functions, util.make_true_uuid() and util.make_str_uuid()",
378 DeprecationWarning,
379 stacklevel=2,
380 )
381 return make_str_uuid()
384def parse_bool(value):
385 """
386 Derive a boolean from the given string value.
387 """
388 if value is None:
389 return None
390 if isinstance(value, bool):
391 return value
392 if str(value).lower() in ("true", "yes", "y", "on", "1"):
393 return True
394 return False
397def parse_list(value):
398 """
399 Parse a configuration value, splitting by whitespace and/or commas
400 and taking quoting into account etc., yielding a list of strings.
401 """
402 if value is None:
403 return []
404 if isinstance(value, list):
405 return value
406 parser = shlex.shlex(value)
407 parser.whitespace += ","
408 parser.whitespace_split = True
409 values = list(parser)
410 for i, val in enumerate(values):
411 if val.startswith('"') and val.endswith('"'):
412 values[i] = val[1:-1]
413 elif val.startswith("'") and val.endswith("'"):
414 values[i] = val[1:-1]
415 return values
418def progress_loop(func, items, factory, message=None):
419 """
420 Convenience function to iterate over a set of items, invoking
421 logic for each, and updating a progress indicator along the way.
423 This function may also be called via the :term:`app handler`; see
424 :meth:`~wuttjamaican.app.AppHandler.progress_loop()`.
426 The ``factory`` will be called to create the progress indicator,
427 which should be an instance of
428 :class:`~wuttjamaican.progress.ProgressBase`.
430 The ``factory`` may also be ``None`` in which case there is no
431 progress, and this is really just a simple "for loop".
433 :param func: Callable to be invoked for each item in the sequence.
434 See below for more details.
436 :param items: Sequence of items over which to iterate.
438 :param factory: Callable which creates/returns a progress
439 indicator, or can be ``None`` for no progress.
441 :param message: Message to display along with the progress
442 indicator. If no message is specified, whether a default is
443 shown will be up to the progress indicator.
445 The ``func`` param should be a callable which accepts 2 positional
446 args ``(obj, i)`` - meaning for which is as follows:
448 :param obj: This will be an item within the sequence.
450 :param i: This will be the *one-based* sequence number for the
451 item.
453 See also :class:`~wuttjamaican.progress.ConsoleProgress` for a
454 usage example.
455 """
456 progress = None
457 if factory:
458 count = len(items)
459 progress = factory(message, count)
461 for i, item in enumerate(items, 1):
462 func(item, i)
463 if progress:
464 progress.update(i)
466 if progress:
467 progress.finish()
470def resource_path(path):
471 """
472 Returns the absolute file path for the given resource path.
474 A "resource path" is one which designates a python package name,
475 plus some path under that. For instance:
477 .. code-block:: none
479 wuttjamaican.email:templates
481 Assuming such a path should exist, the question is "where?"
483 So this function uses :mod:`python:importlib.resources` to locate
484 the path, possibly extracting the file(s) from a zipped package,
485 and returning the final path on disk.
487 It only does this if it detects it is needed, based on the given
488 ``path`` argument. If that is already an absolute path then it
489 will be returned as-is.
491 :param path: Either a package resource specifier as shown above,
492 or regular file path.
494 :returns: Absolute file path to the resource.
495 """
496 if not os.path.isabs(path) and ":" in path:
497 try:
498 # nb. these were added in python 3.9
499 from importlib.resources import ( # pylint: disable=import-outside-toplevel
500 files,
501 as_file,
502 )
503 except ImportError: # python < 3.9
504 from importlib_resources import ( # pylint: disable=import-outside-toplevel
505 files,
506 as_file,
507 )
509 package, filename = path.split(":")
510 ref = files(package) / filename
511 with as_file(ref) as p:
512 return str(p)
514 return path
517def simple_error(error):
518 """
519 Return a "simple" string for the given error. Result will look
520 like::
522 "ErrorClass: Description for the error"
524 However the logic checks to ensure the error has a descriptive
525 message first; if it doesn't the result will just be::
527 "ErrorClass"
528 """
529 cls = type(error).__name__
530 msg = str(error)
531 if msg:
532 return f"{cls}: {msg}"
533 return cls