Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/util.py: 100%
108 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-06 17:01 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-06 17:01 -0600
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 - utilities
25"""
27import importlib
28import logging
29import os
30import shlex
32from uuid_extensions import uuid7
35log = logging.getLogger(__name__)
38# nb. this is used as default kwarg value in some places, to
39# distinguish passing a ``None`` value, vs. *no* value at all
40UNSPECIFIED = object()
43def get_class_hierarchy(klass, topfirst=True):
44 """
45 Returns a list of all classes in the inheritance chain for the
46 given class.
48 For instance::
50 class A:
51 pass
53 class B(A):
54 pass
56 class C(B):
57 pass
59 get_class_hierarchy(C)
60 # -> [A, B, C]
62 :param klass: The reference class. The list of classes returned
63 will include this class and all its parents.
65 :param topfirst: Whether the returned list should be sorted in a
66 "top first" way, e.g. A) grandparent, B) parent, C) child.
67 This is the default but pass ``False`` to get the reverse.
68 """
69 hierarchy = []
71 def traverse(cls):
72 if cls is not object:
73 hierarchy.append(cls)
74 for parent in cls.__bases__:
75 traverse(parent)
77 traverse(klass)
78 if topfirst:
79 hierarchy.reverse()
80 return hierarchy
83def load_entry_points(group, ignore_errors=False):
84 """
85 Load a set of ``setuptools``-style entry points.
87 This is used to locate "plugins" and similar things, e.g. the set
88 of subcommands which belong to a main command.
90 :param group: The group (string name) of entry points to be
91 loaded, e.g. ``'wutta.commands'``.
93 :param ignore_errors: If false (the default), any errors will be
94 raised normally. If true, errors will be logged but not
95 raised.
97 :returns: A dictionary whose keys are the entry point names, and
98 values are the loaded entry points.
99 """
100 entry_points = {}
102 try:
103 # nb. this package was added in python 3.8
104 import importlib.metadata as importlib_metadata
105 except ImportError:
106 import importlib_metadata
108 eps = importlib_metadata.entry_points()
109 if not hasattr(eps, 'select'):
110 # python < 3.10
111 eps = eps.get(group, [])
112 else:
113 # python >= 3.10
114 eps = eps.select(group=group)
115 for entry_point in eps:
116 try:
117 ep = entry_point.load()
118 except:
119 if not ignore_errors:
120 raise
121 log.warning("failed to load entry point: %s", entry_point,
122 exc_info=True)
123 else:
124 entry_points[entry_point.name] = ep
126 return entry_points
129def load_object(spec):
130 """
131 Load an arbitrary object from a module, according to the spec.
133 The spec string should contain a dotted path to an importable module,
134 followed by a colon (``':'``), followed by the name of the object to be
135 loaded. For example:
137 .. code-block:: none
139 wuttjamaican.util:parse_bool
141 You'll notice from this example that "object" in this context refers to any
142 valid Python object, i.e. not necessarily a class instance. The name may
143 refer to a class, function, variable etc. Once the module is imported, the
144 ``getattr()`` function is used to obtain a reference to the named object;
145 therefore anything supported by that approach should work.
147 :param spec: Spec string.
149 :returns: The specified object.
150 """
151 if not spec:
152 raise ValueError("no object spec provided")
154 module_path, name = spec.split(':')
155 module = importlib.import_module(module_path)
156 return getattr(module, name)
159def make_title(text):
160 """
161 Return a human-friendly "title" for the given text.
163 This is mostly useful for converting a Python variable name (or
164 similar) to a human-friendly string, e.g.::
166 make_title('foo_bar') # => 'Foo Bar'
167 """
168 text = text.replace('_', ' ')
169 text = text.replace('-', ' ')
170 words = text.split()
171 return ' '.join([x.capitalize() for x in words])
174def make_full_name(*parts):
175 """
176 Make a "full name" from the given parts.
178 :param \*parts: Distinct name values which should be joined
179 together to make the full name.
181 :returns: The full name.
183 For instance::
185 make_full_name('First', '', 'Last', 'Suffix')
186 # => "First Last Suffix"
187 """
188 parts = [(part or '').strip()
189 for part in parts]
190 parts = [part for part in parts if part]
191 return ' '.join(parts)
194def make_true_uuid():
195 """
196 Generate a new v7 UUID value.
198 :returns: :class:`python:uuid.UUID` instance
200 .. warning::
202 For now, callers should use this function when they want a
203 proper UUID instance, whereas :func:`make_uuid()` will always
204 return a string.
206 However once all dependent logic has been refactored to support
207 proper UUID data type, then ``make_uuid()`` will return those
208 and this function will eventually be removed.
209 """
210 return uuid7()
213# TODO: deprecate this logic, and reclaim this name
214# but using the above logic
215def make_uuid():
216 """
217 Generate a new v7 UUID value.
219 :returns: A 32-character hex string.
221 .. warning::
223 For now, this function always returns a string.
225 However once all dependent logic has been refactored to support
226 proper UUID data type, then this function will return those and
227 the :func:`make_true_uuid()` function will eventually be
228 removed.
229 """
230 return make_true_uuid().hex
233def parse_bool(value):
234 """
235 Derive a boolean from the given string value.
236 """
237 if value is None:
238 return None
239 if isinstance(value, bool):
240 return value
241 if str(value).lower() in ('true', 'yes', 'y', 'on', '1'):
242 return True
243 return False
246def parse_list(value):
247 """
248 Parse a configuration value, splitting by whitespace and/or commas
249 and taking quoting into account etc., yielding a list of strings.
250 """
251 if value is None:
252 return []
253 if isinstance(value, list):
254 return value
255 parser = shlex.shlex(value)
256 parser.whitespace += ','
257 parser.whitespace_split = True
258 values = list(parser)
259 for i, value in enumerate(values):
260 if value.startswith('"') and value.endswith('"'):
261 values[i] = value[1:-1]
262 elif value.startswith("'") and value.endswith("'"):
263 values[i] = value[1:-1]
264 return values
267def progress_loop(func, items, factory, message=None):
268 """
269 Convenience function to iterate over a set of items, invoking
270 logic for each, and updating a progress indicator along the way.
272 This function may also be called via the :term:`app handler`; see
273 :meth:`~wuttjamaican.app.AppHandler.progress_loop()`.
275 The ``factory`` will be called to create the progress indicator,
276 which should be an instance of
277 :class:`~wuttjamaican.progress.ProgressBase`.
279 The ``factory`` may also be ``None`` in which case there is no
280 progress, and this is really just a simple "for loop".
282 :param func: Callable to be invoked for each item in the sequence.
283 See below for more details.
285 :param items: Sequence of items over which to iterate.
287 :param factory: Callable which creates/returns a progress
288 indicator, or can be ``None`` for no progress.
290 :param message: Message to display along with the progress
291 indicator. If no message is specified, whether a default is
292 shown will be up to the progress indicator.
294 The ``func`` param should be a callable which accepts 2 positional
295 args ``(obj, i)`` - meaning for which is as follows:
297 :param obj: This will be an item within the sequence.
299 :param i: This will be the *one-based* sequence number for the
300 item.
302 See also :class:`~wuttjamaican.progress.ConsoleProgress` for a
303 usage example.
304 """
305 progress = None
306 if factory:
307 count = len(items)
308 progress = factory(message, count)
310 for i, item in enumerate(items, 1):
312 func(item, i)
314 if progress:
315 progress.update(i)
317 if progress:
318 progress.finish()
321def resource_path(path):
322 """
323 Returns the absolute file path for the given resource path.
325 A "resource path" is one which designates a python package name,
326 plus some path under that. For instance:
328 .. code-block:: none
330 wuttjamaican.email:templates
332 Assuming such a path should exist, the question is "where?"
334 So this function uses :mod:`python:importlib.resources` to locate
335 the path, possibly extracting the file(s) from a zipped package,
336 and returning the final path on disk.
338 It only does this if it detects it is needed, based on the given
339 ``path`` argument. If that is already an absolute path then it
340 will be returned as-is.
342 :param path: Either a package resource specifier as shown above,
343 or regular file path.
345 :returns: Absolute file path to the resource.
346 """
347 if not os.path.isabs(path) and ':' in path:
349 try:
350 # nb. these were added in python 3.9
351 from importlib.resources import files, as_file
352 except ImportError: # python < 3.9
353 from importlib_resources import files, as_file
355 package, filename = path.split(':')
356 ref = files(package) / filename
357 with as_file(ref) as path:
358 return str(path)
360 return path
363def simple_error(error):
364 """
365 Return a "simple" string for the given error. Result will look
366 like::
368 "ErrorClass: Description for the error"
370 However the logic checks to ensure the error has a descriptive
371 message first; if it doesn't the result will just be::
373 "ErrorClass"
374 """
375 cls = type(error).__name__
376 msg = str(error)
377 if msg:
378 return f"{cls}: {msg}"
379 return cls