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

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

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 load_entry_points(group, ignore_errors=False): 

86 """ 

87 Load a set of ``setuptools``-style entry points. 

88 

89 This is used to locate "plugins" and similar things, e.g. the set 

90 of subcommands which belong to a main command. 

91 

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

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

94 

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. 

98 

99 :returns: A dictionary whose keys are the entry point names, and 

100 values are the loaded entry points. 

101 """ 

102 entry_points = {} 

103 

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 

109 

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 

126 

127 return entry_points 

128 

129 

130def load_object(spec): 

131 """ 

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

133 

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: 

137 

138 .. code-block:: none 

139 

140 wuttjamaican.util:parse_bool 

141 

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. 

147 

148 :param spec: Spec string. 

149 

150 :returns: The specified object. 

151 """ 

152 if not spec: 

153 raise ValueError("no object spec provided") 

154 

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

156 module = importlib.import_module(module_path) 

157 return getattr(module, name) 

158 

159 

160def make_title(text): 

161 """ 

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

163 

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

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

166 

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

173 

174 

175def make_full_name(*parts): 

176 """ 

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

178 

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

180 together to make the full name. 

181 

182 :returns: The full name. 

183 

184 For instance:: 

185 

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) 

192 

193 

194def get_timezone_by_name(tzname): 

195 """ 

196 Retrieve a timezone object by name. 

197 

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

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

200 

201 For Python 3.9 and newer, this instantiates 

202 :class:`python:zoneinfo.ZoneInfo`. 

203 

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

205 

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

207 the app handler. 

208 

209 :param tzname: String name for timezone. 

210 

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

212 """ 

213 try: 

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

215 

216 return ZoneInfo(tzname) 

217 

218 except ImportError: # python 3.8 

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

220 

221 return gettz(tzname) 

222 

223 

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

228 

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

232 

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

234 

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

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

237 

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. 

242 

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. 

248 

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. 

252 

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) 

259 

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) 

270 

271 # convert to local timezone 

272 if local_zone: 

273 dt = dt.astimezone(local_zone) 

274 else: 

275 dt = dt.astimezone() 

276 

277 # maybe strip tzinfo 

278 if want_tzinfo: 

279 return dt 

280 return dt.replace(tzinfo=None) 

281 

282 

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. 

288 

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

292 

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

294 

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

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

297 

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. 

302 

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) 

311 

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) 

319 

320 # naive value returned as-is.. 

321 if not tzinfo: 

322 return dt 

323 

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) 

327 

328 

329# TODO: deprecate / remove this eventually 

330def make_true_uuid(): 

331 """ 

332 Generate a new v7 UUID. 

333 

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

335 

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

337 """ 

338 return uuid7() 

339 

340 

341# TODO: deprecate / remove this eventually 

342def make_str_uuid(): 

343 """ 

344 Generate a new v7 UUID value as string. 

345 

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

347 

348 :returns: UUID as 32-character hex string 

349 """ 

350 return make_true_uuid().hex 

351 

352 

353# TODO: eventually refactor, to return true uuid 

354def make_uuid(): 

355 """ 

356 Generate a new v7 UUID value. 

357 

358 See also the app handler shortcut, 

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

360 

361 :returns: UUID as 32-character hex string 

362 

363 .. warning:: 

364 

365 **TEMPORARY BEHAVIOR** 

366 

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. 

370 

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

382 

383 

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 

395 

396 

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 

416 

417 

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. 

422 

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

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

425 

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

427 which should be an instance of 

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

429 

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

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

432 

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

434 See below for more details. 

435 

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

437 

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

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

440 

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. 

444 

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

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

447 

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

449 

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

451 item. 

452 

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) 

460 

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

462 func(item, i) 

463 if progress: 

464 progress.update(i) 

465 

466 if progress: 

467 progress.finish() 

468 

469 

470def resource_path(path): 

471 """ 

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

473 

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

475 plus some path under that. For instance: 

476 

477 .. code-block:: none 

478 

479 wuttjamaican.email:templates 

480 

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

482 

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. 

486 

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. 

490 

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

492 or regular file path. 

493 

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 ) 

508 

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

510 ref = files(package) / filename 

511 with as_file(ref) as p: 

512 return str(p) 

513 

514 return path 

515 

516 

517def simple_error(error): 

518 """ 

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

520 like:: 

521 

522 "ErrorClass: Description for the error" 

523 

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

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

526 

527 "ErrorClass" 

528 """ 

529 cls = type(error).__name__ 

530 msg = str(error) 

531 if msg: 

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

533 return cls