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

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""" 

26 

27import datetime 

28import importlib 

29import logging 

30import os 

31import shlex 

32import warnings 

33 

34from uuid_extensions import uuid7 

35 

36 

37log = logging.getLogger(__name__) 

38 

39 

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() 

43 

44 

45def get_class_hierarchy(klass, topfirst=True): 

46 """ 

47 Returns a list of all classes in the inheritance chain for the 

48 given class. 

49 

50 For instance:: 

51 

52 class A: 

53 pass 

54 

55 class B(A): 

56 pass 

57 

58 class C(B): 

59 pass 

60 

61 get_class_hierarchy(C) 

62 # -> [A, B, C] 

63 

64 :param klass: The reference class. The list of classes returned 

65 will include this class and all its parents. 

66 

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 = [] 

72 

73 def traverse(cls): 

74 if cls is not object: 

75 hierarchy.append(cls) 

76 for parent in cls.__bases__: 

77 traverse(parent) 

78 

79 traverse(klass) 

80 if topfirst: 

81 hierarchy.reverse() 

82 return hierarchy 

83 

84 

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. 

90 

91 :param obj: Arbitrary dict or object of any kind which would have 

92 named attributes. 

93 

94 :param key: Key/name of the field to get. 

95 

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] 

103 

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) 

111 

112 

113def load_entry_points(group, lists=False, ignore_errors=False): 

114 """ 

115 Load a set of ``setuptools``-style :term:`entry points <entry 

116 point>`. 

117 

118 This is used to locate "plugins" and similar things, e.g. discover 

119 which batch handlers are installed. 

120 

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. 

124 

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.) 

131 

132 :param group: The group (string name) of entry points to be 

133 loaded, e.g. ``'wutta.commands'``. 

134 

135 :param lists: Whether to return lists instead of single object 

136 values. 

137 

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. 

141 

142 :returns: A dict of entry points, as described above. 

143 """ 

144 entry_points = {} 

145 

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 

151 

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 

183 

184 return entry_points 

185 

186 

187def load_object(spec): 

188 """ 

189 Load an arbitrary object from a module, according to the spec. 

190 

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: 

194 

195 .. code-block:: none 

196 

197 wuttjamaican.util:parse_bool 

198 

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. 

204 

205 :param spec: Spec string. 

206 

207 :returns: The specified object. 

208 """ 

209 if not spec: 

210 raise ValueError("no object spec provided") 

211 

212 module_path, name = spec.split(":") 

213 module = importlib.import_module(module_path) 

214 return getattr(module, name) 

215 

216 

217def make_title(text): 

218 """ 

219 Return a human-friendly "title" for the given text. 

220 

221 This is mostly useful for converting a Python variable name (or 

222 similar) to a human-friendly string, e.g.:: 

223 

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]) 

230 

231 

232def make_full_name(*parts): 

233 """ 

234 Make a "full name" from the given parts. 

235 

236 :param \\*parts: Distinct name values which should be joined 

237 together to make the full name. 

238 

239 :returns: The full name. 

240 

241 For instance:: 

242 

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) 

249 

250 

251def get_timezone_by_name(tzname): 

252 """ 

253 Retrieve a timezone object by name. 

254 

255 This is mostly a compatibility wrapper, since older Python is 

256 missing the :mod:`python:zoneinfo` module. 

257 

258 For Python 3.9 and newer, this instantiates 

259 :class:`python:zoneinfo.ZoneInfo`. 

260 

261 For Python 3.8, this calls :func:`dateutil:dateutil.tz.gettz()`. 

262 

263 See also :meth:`~wuttjamaican.app.AppHandler.get_timezone()` on 

264 the app handler. 

265 

266 :param tzname: String name for timezone. 

267 

268 :returns: :class:`python:datetime.tzinfo` instance 

269 """ 

270 try: 

271 from zoneinfo import ZoneInfo # pylint: disable=import-outside-toplevel 

272 

273 return ZoneInfo(tzname) 

274 

275 except ImportError: # python 3.8 

276 from dateutil.tz import gettz # pylint: disable=import-outside-toplevel 

277 

278 return gettz(tzname) 

279 

280 

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*. 

285 

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`. 

289 

290 See also :func:`make_utc()` which is sort of the inverse. 

291 

292 :param dt: Optional :class:`python:datetime.datetime` instance. 

293 If not specified, the current time will be used. 

294 

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. 

299 

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. 

305 

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. 

309 

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) 

316 

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) 

327 

328 # convert to local timezone 

329 if local_zone: 

330 dt = dt.astimezone(local_zone) 

331 else: 

332 dt = dt.astimezone() 

333 

334 # maybe strip tzinfo 

335 if want_tzinfo: 

336 return dt 

337 return dt.replace(tzinfo=None) 

338 

339 

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. 

345 

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`. 

349 

350 See also :func:`localtime()` which is sort of the inverse. 

351 

352 :param dt: Optional :class:`python:datetime.datetime` instance. 

353 If not specified, the current time will be used. 

354 

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. 

359 

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) 

368 

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) 

376 

377 # naive value returned as-is.. 

378 if not tzinfo: 

379 return dt 

380 

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) 

384 

385 

386# TODO: deprecate / remove this eventually 

387def make_true_uuid(): 

388 """ 

389 Generate a new v7 UUID. 

390 

391 See also :func:`make_uuid()`. 

392 

393 :returns: :class:`python:uuid.UUID` instance 

394 """ 

395 return uuid7() 

396 

397 

398# TODO: deprecate / remove this eventually 

399def make_str_uuid(): 

400 """ 

401 Generate a new v7 UUID value as string. 

402 

403 See also :func:`make_uuid()`. 

404 

405 :returns: UUID as 32-character hex string 

406 """ 

407 return make_true_uuid().hex 

408 

409 

410# TODO: eventually refactor, to return true uuid 

411def make_uuid(): 

412 """ 

413 Generate a new v7 UUID value. 

414 

415 See also the app handler shortcut, 

416 :meth:`~wuttjamaican.app.AppHandler.make_uuid()`. 

417 

418 :returns: UUID as 32-character hex string 

419 

420 .. warning:: 

421 

422 **TEMPORARY BEHAVIOR** 

423 

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. 

427 

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() 

439 

440 

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 

452 

453 

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 

473 

474 

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. 

479 

480 This function may also be called via the :term:`app handler`; see 

481 :meth:`~wuttjamaican.app.AppHandler.progress_loop()`. 

482 

483 The ``factory`` will be called to create the progress indicator, 

484 which should be an instance of 

485 :class:`~wuttjamaican.progress.ProgressBase`. 

486 

487 The ``factory`` may also be ``None`` in which case there is no 

488 progress, and this is really just a simple "for loop". 

489 

490 :param func: Callable to be invoked for each item in the sequence. 

491 See below for more details. 

492 

493 :param items: Sequence of items over which to iterate. 

494 

495 :param factory: Callable which creates/returns a progress 

496 indicator, or can be ``None`` for no progress. 

497 

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. 

501 

502 The ``func`` param should be a callable which accepts 2 positional 

503 args ``(obj, i)`` - meaning for which is as follows: 

504 

505 :param obj: This will be an item within the sequence. 

506 

507 :param i: This will be the *one-based* sequence number for the 

508 item. 

509 

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) 

517 

518 for i, item in enumerate(items, 1): 

519 func(item, i) 

520 if progress: 

521 progress.update(i) 

522 

523 if progress: 

524 progress.finish() 

525 

526 

527def resource_path(path): 

528 """ 

529 Returns the absolute file path for the given resource path. 

530 

531 A "resource path" is one which designates a python package name, 

532 plus some path under that. For instance: 

533 

534 .. code-block:: none 

535 

536 wuttjamaican.email:templates 

537 

538 Assuming such a path should exist, the question is "where?" 

539 

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. 

543 

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. 

547 

548 :param path: Either a package resource specifier as shown above, 

549 or regular file path. 

550 

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 ) 

565 

566 package, filename = path.split(":") 

567 ref = files(package) / filename 

568 with as_file(ref) as p: 

569 return str(p) 

570 

571 return path 

572 

573 

574def simple_error(error): 

575 """ 

576 Return a "simple" string for the given error. Result will look 

577 like:: 

578 

579 "ErrorClass: Description for the error" 

580 

581 However the logic checks to ensure the error has a descriptive 

582 message first; if it doesn't the result will just be:: 

583 

584 "ErrorClass" 

585 """ 

586 cls = type(error).__name__ 

587 msg = str(error) 

588 if msg: 

589 return f"{cls}: {msg}" 

590 return cls