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

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

26 

27import importlib 

28import logging 

29import os 

30import shlex 

31 

32from uuid_extensions import uuid7 

33 

34 

35log = logging.getLogger(__name__) 

36 

37 

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

41 

42 

43def get_class_hierarchy(klass, topfirst=True): 

44 """ 

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

46 given class. 

47 

48 For instance:: 

49 

50 class A: 

51 pass 

52 

53 class B(A): 

54 pass 

55 

56 class C(B): 

57 pass 

58 

59 get_class_hierarchy(C) 

60 # -> [A, B, C] 

61 

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

63 will include this class and all its parents. 

64 

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

70 

71 def traverse(cls): 

72 if cls is not object: 

73 hierarchy.append(cls) 

74 for parent in cls.__bases__: 

75 traverse(parent) 

76 

77 traverse(klass) 

78 if topfirst: 

79 hierarchy.reverse() 

80 return hierarchy 

81 

82 

83def load_entry_points(group, ignore_errors=False): 

84 """ 

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

86 

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

88 of subcommands which belong to a main command. 

89 

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

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

92 

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. 

96 

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

98 values are the loaded entry points. 

99 """ 

100 entry_points = {} 

101 

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 

107 

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 

125 

126 return entry_points 

127 

128 

129def load_object(spec): 

130 """ 

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

132 

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: 

136 

137 .. code-block:: none 

138 

139 wuttjamaican.util:parse_bool 

140 

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. 

146 

147 :param spec: Spec string. 

148 

149 :returns: The specified object. 

150 """ 

151 if not spec: 

152 raise ValueError("no object spec provided") 

153 

154 module_path, name = spec.split(':') 

155 module = importlib.import_module(module_path) 

156 return getattr(module, name) 

157 

158 

159def make_title(text): 

160 """ 

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

162 

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

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

165 

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

172 

173 

174def make_full_name(*parts): 

175 """ 

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

177 

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

179 together to make the full name. 

180 

181 :returns: The full name. 

182 

183 For instance:: 

184 

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) 

192 

193 

194def make_true_uuid(): 

195 """ 

196 Generate a new v7 UUID value. 

197 

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

199 

200 .. warning:: 

201 

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. 

205 

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

211 

212 

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. 

218 

219 :returns: A 32-character hex string. 

220 

221 .. warning:: 

222 

223 For now, this function always returns a string. 

224 

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 

231 

232 

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 

244 

245 

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 

265 

266 

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. 

271 

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

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

274 

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

276 which should be an instance of 

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

278 

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

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

281 

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

283 See below for more details. 

284 

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

286 

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

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

289 

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. 

293 

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

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

296 

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

298 

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

300 item. 

301 

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) 

309 

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

311 

312 func(item, i) 

313 

314 if progress: 

315 progress.update(i) 

316 

317 if progress: 

318 progress.finish() 

319 

320 

321def resource_path(path): 

322 """ 

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

324 

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

326 plus some path under that. For instance: 

327 

328 .. code-block:: none 

329 

330 wuttjamaican.email:templates 

331 

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

333 

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. 

337 

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. 

341 

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

343 or regular file path. 

344 

345 :returns: Absolute file path to the resource. 

346 """ 

347 if not os.path.isabs(path) and ':' in path: 

348 

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 

354 

355 package, filename = path.split(':') 

356 ref = files(package) / filename 

357 with as_file(ref) as path: 

358 return str(path) 

359 

360 return path 

361 

362 

363def simple_error(error): 

364 """ 

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

366 like:: 

367 

368 "ErrorClass: Description for the error" 

369 

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

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

372 

373 "ErrorClass" 

374 """ 

375 cls = type(error).__name__ 

376 msg = str(error) 

377 if msg: 

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

379 return cls