Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/app.py: 100%

358 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-02-25 15:39 -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 - app handler 

25""" 

26# pylint: disable=too-many-lines 

27 

28import datetime 

29import logging 

30import os 

31import sys 

32import warnings 

33import importlib 

34from importlib.metadata import version 

35 

36import humanize 

37from webhelpers2.html import HTML 

38 

39from wuttjamaican.util import ( 

40 get_timezone_by_name, 

41 get_value, 

42 localtime, 

43 load_entry_points, 

44 load_object, 

45 make_title, 

46 make_full_name, 

47 make_utc, 

48 make_uuid, 

49 make_str_uuid, 

50 make_true_uuid, 

51 progress_loop, 

52 resource_path, 

53 simple_error, 

54) 

55 

56 

57log = logging.getLogger(__name__) 

58 

59 

60class AppHandler: # pylint: disable=too-many-public-methods 

61 """ 

62 Base class and default implementation for top-level :term:`app 

63 handler`. 

64 

65 aka. "the handler to handle all handlers" 

66 

67 aka. "one handler to bind them all" 

68 

69 For more info see :doc:`/narr/handlers/app`. 

70 

71 There is normally no need to create one of these yourself; rather 

72 you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()` 

73 on the :term:`config object` if you need the app handler. 

74 

75 :param config: Config object for the app. This should be an 

76 instance of :class:`~wuttjamaican.conf.WuttaConfig`. 

77 

78 .. attribute:: model 

79 

80 Reference to the :term:`app model` module. 

81 

82 Note that :meth:`get_model()` is responsible for determining 

83 which module this will point to. However you can always get 

84 the model using this attribute (e.g. ``app.model``) and do not 

85 need to call :meth:`get_model()` yourself - that part will 

86 happen automatically. 

87 

88 .. attribute:: enum 

89 

90 Reference to the :term:`app enum` module. 

91 

92 Note that :meth:`get_enum()` is responsible for determining 

93 which module this will point to. However you can always get 

94 the model using this attribute (e.g. ``app.enum``) and do not 

95 need to call :meth:`get_enum()` yourself - that part will 

96 happen automatically. 

97 

98 .. attribute:: providers 

99 

100 Dictionary of :class:`AppProvider` instances, as returned by 

101 :meth:`get_all_providers()`. 

102 """ 

103 

104 default_app_title = "WuttJamaican" 

105 default_model_spec = "wuttjamaican.db.model" 

106 default_enum_spec = "wuttjamaican.enum" 

107 default_auth_handler_spec = "wuttjamaican.auth:AuthHandler" 

108 default_db_handler_spec = "wuttjamaican.db.handler:DatabaseHandler" 

109 default_email_handler_spec = "wuttjamaican.email:EmailHandler" 

110 default_install_handler_spec = "wuttjamaican.install:InstallHandler" 

111 default_people_handler_spec = "wuttjamaican.people:PeopleHandler" 

112 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler" 

113 default_report_handler_spec = "wuttjamaican.reports:ReportHandler" 

114 

115 def __init__(self, config): 

116 self.config = config 

117 self.handlers = {} 

118 self.timezones = {} 

119 

120 @property 

121 def appname(self): 

122 """ 

123 The :term:`app name` for the current app. This is just an 

124 alias for :attr:`wuttjamaican.conf.WuttaConfig.appname`. 

125 

126 Note that this ``appname`` does not necessariy reflect what 

127 you think of as the name of your (e.g. custom) app. It is 

128 more fundamental than that; your Python package naming and the 

129 :term:`app title` are free to use a different name as their 

130 basis. 

131 """ 

132 return self.config.appname 

133 

134 def __getattr__(self, name): 

135 """ 

136 Custom attribute getter, called when the app handler does not 

137 already have an attribute with the given ``name``. 

138 

139 This will delegate to the set of :term:`app providers<app 

140 provider>`; the first provider with an appropriately-named 

141 attribute wins, and that value is returned. 

142 

143 :returns: The first value found among the set of app 

144 providers. 

145 """ 

146 

147 if name == "model": 

148 return self.get_model() 

149 

150 if name == "enum": 

151 return self.get_enum() 

152 

153 if name == "providers": 

154 self.__dict__["providers"] = self.get_all_providers() 

155 return self.providers 

156 

157 for provider in self.providers.values(): 

158 if hasattr(provider, name): 

159 return getattr(provider, name) 

160 

161 raise AttributeError(f"attr not found: {name}") 

162 

163 def get_all_providers(self): 

164 """ 

165 Load and return all registered providers. 

166 

167 Note that you do not need to call this directly; instead just 

168 use :attr:`providers`. 

169 

170 The discovery logic is based on :term:`entry points<entry 

171 point>` using the ``wutta.app.providers`` group. For instance 

172 here is a sample entry point used by WuttaWeb (in its 

173 ``pyproject.toml``): 

174 

175 .. code-block:: toml 

176 

177 [project.entry-points."wutta.app.providers"] 

178 wuttaweb = "wuttaweb.app:WebAppProvider" 

179 

180 :returns: Dictionary keyed by entry point name; values are 

181 :class:`AppProvider` instances. 

182 """ 

183 # nb. must use 'wutta' and not self.appname prefix here, or 

184 # else we can't find all providers with custom appname 

185 providers = load_entry_points("wutta.app.providers") 

186 for key in list(providers): 

187 providers[key] = providers[key](self.config) 

188 return providers 

189 

190 def get_title(self, default=None): 

191 """ 

192 Returns the configured title for the app. 

193 

194 :param default: Value to be returned if there is no app title 

195 configured. 

196 

197 :returns: Title for the app. 

198 """ 

199 return self.config.get( 

200 f"{self.appname}.app_title", default=default or self.default_app_title 

201 ) 

202 

203 def get_node_title(self, default=None): 

204 """ 

205 Returns the configured title for the local app node. 

206 

207 If none is configured, and no default provided, will return 

208 the value from :meth:`get_title()`. 

209 

210 :param default: Value to use if the node title is not 

211 configured. 

212 

213 :returns: Title for the local app node. 

214 """ 

215 title = self.config.get(f"{self.appname}.node_title") 

216 if title: 

217 return title 

218 return self.get_title(default=default) 

219 

220 def get_node_type(self, default=None): 

221 """ 

222 Returns the "type" of current app node. 

223 

224 The framework itself does not (yet?) have any notion of what a 

225 node type means. This abstraction is here for convenience, in 

226 case it is needed by a particular app ecosystem. 

227 

228 :returns: String name for the node type, or ``None``. 

229 

230 The node type must be configured via file; this cannot be done 

231 with a DB setting. Depending on :attr:`appname` that is like 

232 so: 

233 

234 .. code-block:: ini 

235 

236 [wutta] 

237 node_type = warehouse 

238 """ 

239 return self.config.get( 

240 f"{self.appname}.node_type", default=default, usedb=False 

241 ) 

242 

243 def get_distribution(self, obj=None): 

244 """ 

245 Returns the appropriate Python distribution name. 

246 

247 If ``obj`` is specified, this will attempt to locate the 

248 distribution based on the top-level module which contains the 

249 object's type/class. 

250 

251 If ``obj`` is *not* specified, this behaves a bit differently. 

252 It first will look for a :term:`config setting` named 

253 ``wutta.app_dist`` (or similar, depending on :attr:`appname`). 

254 If there is such a config value, it is returned. Otherwise 

255 the "auto-locate" logic described above happens, but using 

256 ``self`` instead of ``obj``. 

257 

258 In other words by default this returns the distribution to 

259 which the running :term:`app handler` belongs. 

260 

261 See also :meth:`get_version()`. 

262 

263 :param obj: Any object which may be used as a clue to locate 

264 the appropriate distribution. 

265 

266 :returns: string, or ``None`` 

267 

268 Also note that a *distribution* name is different from a 

269 *package* name. The distribution name is how things appear on 

270 PyPI for instance. 

271 

272 If you want to override the default distribution name (and 

273 skip the auto-locate based on app handler) then you can define 

274 it in config: 

275 

276 .. code-block:: ini 

277 

278 [wutta] 

279 app_dist = My-Poser-Dist 

280 """ 

281 if obj is None: 

282 dist = self.config.get(f"{self.appname}.app_dist") 

283 if dist: 

284 return dist 

285 

286 # TODO: do we need a config setting for app_package ? 

287 # modpath = self.config.get(f'{self.appname}.app_package') 

288 modpath = None 

289 if not modpath: 

290 modpath = type(obj if obj is not None else self).__module__ 

291 pkgname = modpath.split(".")[0] 

292 

293 try: 

294 from importlib.metadata import ( # pylint: disable=import-outside-toplevel 

295 packages_distributions, 

296 ) 

297 except ImportError: # python < 3.10 

298 from importlib_metadata import ( # pylint: disable=import-outside-toplevel 

299 packages_distributions, 

300 ) 

301 

302 pkgmap = packages_distributions() 

303 if pkgname in pkgmap: 

304 dist = pkgmap[pkgname][0] 

305 return dist 

306 

307 # fall back to configured dist, if obj lookup failed 

308 return self.config.get(f"{self.appname}.app_dist") 

309 

310 def get_version(self, dist=None, obj=None): 

311 """ 

312 Returns the version of a given Python distribution. 

313 

314 If ``dist`` is not specified, calls :meth:`get_distribution()` 

315 to get it. (It passes ``obj`` along for this). 

316 

317 So by default this will return the version of whichever 

318 distribution owns the running :term:`app handler`. 

319 

320 :returns: Version as string. 

321 """ 

322 if not dist: 

323 dist = self.get_distribution(obj=obj) 

324 if dist: 

325 return version(dist) 

326 return None 

327 

328 def get_model(self): 

329 """ 

330 Returns the :term:`app model` module. 

331 

332 Note that you don't actually need to call this method; you can 

333 get the model by simply accessing :attr:`model` 

334 (e.g. ``app.model``) instead. 

335 

336 By default this will return :mod:`wuttjamaican.db.model` 

337 unless the config class or some :term:`config extension` has 

338 provided another default. 

339 

340 A custom app can override the default like so (within a config 

341 extension):: 

342 

343 config.setdefault('wutta.model_spec', 'poser.db.model') 

344 """ 

345 if "model" not in self.__dict__: 

346 spec = self.config.get( 

347 f"{self.appname}.model_spec", 

348 usedb=False, 

349 default=self.default_model_spec, 

350 ) 

351 self.__dict__["model"] = importlib.import_module(spec) 

352 return self.model 

353 

354 def get_enum(self): 

355 """ 

356 Returns the :term:`app enum` module. 

357 

358 Note that you don't actually need to call this method; you can 

359 get the module by simply accessing :attr:`enum` 

360 (e.g. ``app.enum``) instead. 

361 

362 By default this will return :mod:`wuttjamaican.enum` unless 

363 the config class or some :term:`config extension` has provided 

364 another default. 

365 

366 A custom app can override the default like so (within a config 

367 extension):: 

368 

369 config.setdefault('wutta.enum_spec', 'poser.enum') 

370 """ 

371 if "enum" not in self.__dict__: 

372 spec = self.config.get( 

373 f"{self.appname}.enum_spec", usedb=False, default=self.default_enum_spec 

374 ) 

375 self.__dict__["enum"] = importlib.import_module(spec) 

376 return self.enum 

377 

378 def get_value(self, obj, key): 

379 """ 

380 Convenience wrapper around 

381 :func:`wuttjamaican.util.get_value()`. 

382 

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

384 have named attributes. 

385 

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

387 

388 :returns: Whatever value is found. Or maybe an 

389 ``AttributeError`` is raised if the object does not have 

390 the key/attr set. 

391 """ 

392 return get_value(obj, key) 

393 

394 def load_object(self, spec): 

395 """ 

396 Import and/or load and return the object designated by the 

397 given spec string. 

398 

399 This invokes :func:`wuttjamaican.util.load_object()`. 

400 

401 :param spec: String of the form ``module.dotted.path:objname``. 

402 

403 :returns: The object referred to by ``spec``. If the module 

404 could not be imported, or did not contain an object of the 

405 given name, then an error will raise. 

406 """ 

407 return load_object(spec) 

408 

409 def get_appdir(self, *args, **kwargs): 

410 """ 

411 Returns path to the :term:`app dir`. 

412 

413 This does not check for existence of the path, it only reads 

414 it from config or (optionally) provides a default path. 

415 

416 :param configured_only: Pass ``True`` here if you only want 

417 the configured path and ignore the default path. 

418 

419 :param create: Pass ``True`` here if you want to ensure the 

420 returned path exists, creating it if necessary. 

421 

422 :param \\*args: Any additional args will be added as child 

423 paths for the final value. 

424 

425 For instance, assuming ``/srv/envs/poser`` is the virtual 

426 environment root:: 

427 

428 app.get_appdir() # => /srv/envs/poser/app 

429 

430 app.get_appdir('data') # => /srv/envs/poser/app/data 

431 """ 

432 configured_only = kwargs.pop("configured_only", False) 

433 create = kwargs.pop("create", False) 

434 

435 # maybe specify default path 

436 if not configured_only: 

437 path = os.path.join(sys.prefix, "app") 

438 kwargs.setdefault("default", path) 

439 

440 # get configured path 

441 kwargs.setdefault("usedb", False) 

442 path = self.config.get(f"{self.appname}.appdir", **kwargs) 

443 

444 # add any subpath info 

445 if path and args: 

446 path = os.path.join(path, *args) 

447 

448 # create path if requested/needed 

449 if create: 

450 if not path: 

451 raise ValueError("appdir path unknown! so cannot create it.") 

452 if not os.path.exists(path): 

453 os.makedirs(path) 

454 

455 return path 

456 

457 def make_appdir(self, path, subfolders=None): 

458 """ 

459 Establish an :term:`app dir` at the given path. 

460 

461 Default logic only creates a few subfolders, meant to help 

462 steer the admin toward a convention for sake of where to put 

463 things. But custom app handlers are free to do whatever. 

464 

465 :param path: Path to the desired app dir. If the path does 

466 not yet exist then it will be created. But regardless it 

467 should be "refreshed" (e.g. missing subfolders created) 

468 when this method is called. 

469 

470 :param subfolders: Optional list of subfolder names to create 

471 within the app dir. If not specified, defaults will be: 

472 ``['cache', 'data', 'log', 'work']``. 

473 """ 

474 appdir = path 

475 if not os.path.exists(appdir): 

476 os.makedirs(appdir) 

477 

478 if not subfolders: 

479 subfolders = ["cache", "data", "log", "work"] 

480 

481 for name in subfolders: 

482 path = os.path.join(appdir, name) 

483 if not os.path.exists(path): 

484 os.mkdir(path) 

485 

486 def render_mako_template( 

487 self, 

488 template, 

489 context, 

490 output_path=None, 

491 ): 

492 """ 

493 Convenience method to render a Mako template. 

494 

495 :param template: :class:`~mako:mako.template.Template` 

496 instance. 

497 

498 :param context: Dict of context for the template. 

499 

500 :param output_path: Optional path to which output should be 

501 written. 

502 

503 :returns: Rendered output as string. 

504 """ 

505 output = template.render(**context) 

506 if output_path: 

507 with open(output_path, "wt", encoding="utf_8") as f: 

508 f.write(output) 

509 return output 

510 

511 def resource_path(self, path): 

512 """ 

513 Convenience wrapper for 

514 :func:`wuttjamaican.util.resource_path()`. 

515 """ 

516 return resource_path(path) 

517 

518 def make_session(self, **kwargs): 

519 """ 

520 Creates a new SQLAlchemy session for the app DB. By default 

521 this will create a new :class:`~wuttjamaican.db.sess.Session` 

522 instance. 

523 

524 :returns: SQLAlchemy session for the app DB. 

525 """ 

526 from .db import Session # pylint: disable=import-outside-toplevel 

527 

528 return Session(**kwargs) 

529 

530 def make_title(self, text): 

531 """ 

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

533 

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

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

536 

537 make_title('foo_bar') # => 'Foo Bar' 

538 

539 By default this just invokes 

540 :func:`wuttjamaican.util.make_title()`. 

541 """ 

542 return make_title(text) 

543 

544 def make_full_name(self, *parts): 

545 """ 

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

547 

548 This is a convenience wrapper around 

549 :func:`~wuttjamaican.util.make_full_name()`. 

550 """ 

551 return make_full_name(*parts) 

552 

553 def get_timezone(self, key="default"): 

554 """ 

555 Get the configured (or system default) timezone object. 

556 

557 This checks config for a setting which corresponds to the 

558 given ``key``, then calls 

559 :func:`~wuttjamaican.util.get_timezone_by_name()` to get the 

560 actual timezone object. 

561 

562 The default key corresponds to the true "local" timezone, but 

563 other keys may correspond to other configured timezones (if 

564 applicable). 

565 

566 As a special case for the default key only: If no config value 

567 is found, Python itself will determine the default system 

568 local timezone. 

569 

570 For any non-default key, an error is raised if no config value 

571 is found. 

572 

573 .. note:: 

574 

575 The app handler *caches* all timezone objects, to avoid 

576 unwanted repetitive lookups when processing multiple 

577 datetimes etc. (Since this method is called by 

578 :meth:`localtime()`.) Therefore whenever timezone config 

579 values are changed, an app restart will be necessary. 

580 

581 Example config: 

582 

583 .. code-block:: ini 

584 

585 [wutta] 

586 timezone.default = America/Chicago 

587 timezone.westcoast = America/Los_Angeles 

588 

589 Example usage:: 

590 

591 tz_default = app.get_timezone() 

592 tz_westcoast = app.get_timezone("westcoast") 

593 

594 See also :meth:`get_timezone_name()`. 

595 

596 :param key: Config key for desired timezone. 

597 

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

599 """ 

600 if key not in self.timezones: 

601 setting = f"{self.appname}.timezone.{key}" 

602 tzname = self.config.get(setting) 

603 if tzname: 

604 self.timezones[key] = get_timezone_by_name(tzname) 

605 

606 elif key == "default": 

607 # fallback to system default 

608 self.timezones[key] = datetime.datetime.now().astimezone().tzinfo 

609 

610 else: 

611 # alternate key was specified, but no config found, so check 

612 # again with require() to force error 

613 self.timezones[key] = self.config.require(setting) 

614 

615 return self.timezones[key] 

616 

617 def get_timezone_name(self, key="default"): 

618 """ 

619 Get the display name for the configured (or system default) 

620 timezone. 

621 

622 This calls :meth:`get_timezone()` and then uses some 

623 heuristics to determine the name. 

624 

625 :param key: Config key for desired timezone. 

626 

627 :returns: String name for the timezone. 

628 """ 

629 tz = self.get_timezone(key=key) 

630 try: 

631 # TODO: this should work for zoneinfo.ZoneInfo objects, 

632 # but not sure yet about dateutils.tz ? 

633 return tz.key 

634 except AttributeError: 

635 # this should work for system default fallback, afaik 

636 dt = datetime.datetime.now(tz) 

637 return dt.tzname() 

638 

639 def localtime(self, dt=None, local_zone=None, **kw): 

640 """ 

641 This produces a datetime in the "local" timezone. 

642 

643 This is a convenience wrapper around 

644 :func:`~wuttjamaican.util.localtime()`; however it also calls 

645 :meth:`get_timezone()` to override the ``local_zone`` param 

646 (unless caller specifies that). 

647 

648 For usage examples see :ref:`convert-to-localtime`. 

649 

650 See also :meth:`make_utc()` which is sort of the inverse; and 

651 :meth:`today()`. 

652 """ 

653 kw["local_zone"] = local_zone or self.get_timezone() 

654 return localtime(dt=dt, **kw) 

655 

656 def make_utc(self, dt=None, tzinfo=False): 

657 """ 

658 This returns a datetime local to the UTC timezone. It is a 

659 convenience wrapper around 

660 :func:`~wuttjamaican.util.make_utc()`. 

661 

662 For usage examples see :ref:`convert-to-utc`. 

663 

664 See also :meth:`localtime()` which is sort of the inverse. 

665 """ 

666 return make_utc(dt=dt, tzinfo=tzinfo) 

667 

668 def today(self): 

669 """ 

670 Convenience method to return the current date, according 

671 to local time zone. 

672 

673 See also :meth:`localtime()`. 

674 

675 :returns: :class:`python:datetime.date` instance 

676 """ 

677 return self.localtime().date() 

678 

679 # TODO: deprecate / remove this eventually 

680 def make_true_uuid(self): 

681 """ 

682 Generate a new :term:`UUID <uuid>`. 

683 

684 This is a convenience around 

685 :func:`~wuttjamaican.util.make_true_uuid()`. 

686 

687 See also :meth:`make_uuid()`. 

688 

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

690 """ 

691 return make_true_uuid() 

692 

693 # TODO: deprecate / remove this eventually 

694 def make_str_uuid(self): 

695 """ 

696 Generate a new :term:`UUID <uuid>` string. 

697 

698 This is a convenience around 

699 :func:`~wuttjamaican.util.make_str_uuid()`. 

700 

701 See also :meth:`make_uuid()`. 

702 

703 :returns: UUID value as 32-character string. 

704 """ 

705 return make_str_uuid() 

706 

707 # TODO: eventually refactor, to return true uuid 

708 def make_uuid(self): 

709 """ 

710 Generate a new :term:`UUID <uuid>` (for now, as string). 

711 

712 This is a convenience around 

713 :func:`~wuttjamaican.util.make_uuid()`. 

714 

715 :returns: UUID as 32-character hex string 

716 

717 .. warning:: 

718 

719 **TEMPORARY BEHAVIOR** 

720 

721 For the moment, use of this method is discouraged. Instead 

722 you should use :meth:`make_true_uuid()` or 

723 :meth:`make_str_uuid()` to be explicit about the return 

724 type you expect. 

725 

726 *Eventually* (once it's clear most/all callers are using 

727 the explicit methods) this will be refactored to return a 

728 UUID instance. But for now this method returns a string. 

729 """ 

730 warnings.warn( 

731 "app.make_uuid() is temporarily deprecated, in favor of " 

732 "explicit methods, app.make_true_uuid() and app.make_str_uuid()", 

733 DeprecationWarning, 

734 stacklevel=2, 

735 ) 

736 return make_uuid() 

737 

738 def progress_loop(self, *args, **kwargs): 

739 """ 

740 Convenience method to iterate over a set of items, invoking 

741 logic for each, and updating a progress indicator along the 

742 way. 

743 

744 This is a wrapper around 

745 :func:`wuttjamaican.util.progress_loop()`; see those docs for 

746 param details. 

747 """ 

748 return progress_loop(*args, **kwargs) 

749 

750 def get_session(self, obj): 

751 """ 

752 Returns the SQLAlchemy session with which the given object is 

753 associated. Simple convenience wrapper around 

754 :func:`sqlalchemy:sqlalchemy.orm.object_session()`. 

755 """ 

756 from sqlalchemy import orm # pylint: disable=import-outside-toplevel 

757 

758 return orm.object_session(obj) 

759 

760 def short_session(self, **kwargs): 

761 """ 

762 Returns a context manager for a short-lived database session. 

763 

764 This is a convenience wrapper around 

765 :class:`~wuttjamaican.db.sess.short_session`. 

766 

767 If caller does not specify ``factory`` nor ``config`` params, 

768 this method will provide a default factory in the form of 

769 :meth:`make_session`. 

770 """ 

771 from .db import short_session # pylint: disable=import-outside-toplevel 

772 

773 if "factory" not in kwargs and "config" not in kwargs: 

774 kwargs["factory"] = self.make_session 

775 

776 return short_session(**kwargs) 

777 

778 def get_setting(self, session, name, **kwargs): # pylint: disable=unused-argument 

779 """ 

780 Get a :term:`config setting` value from the DB. 

781 

782 This does *not* consult the :term:`config object` directly to 

783 determine the setting value; it always queries the DB. 

784 

785 Default implementation is just a convenience wrapper around 

786 :func:`~wuttjamaican.db.conf.get_setting()`. 

787 

788 See also :meth:`save_setting()` and :meth:`delete_setting()`. 

789 

790 :param session: App DB session. 

791 

792 :param name: Name of the setting to get. 

793 

794 :param \\**kwargs: Any remaining kwargs are ignored by the 

795 default logic, but subclass may override. 

796 

797 :returns: Setting value as string, or ``None``. 

798 """ 

799 from .db import get_setting # pylint: disable=import-outside-toplevel 

800 

801 return get_setting(session, name) 

802 

803 def save_setting( 

804 self, session, name, value, force_create=False, **kwargs 

805 ): # pylint: disable=unused-argument 

806 """ 

807 Save a :term:`config setting` value to the DB. 

808 

809 See also :meth:`get_setting()` and :meth:`delete_setting()`. 

810 

811 :param session: Current :term:`db session`. 

812 

813 :param name: Name of the setting to save. 

814 

815 :param value: Value to be saved for the setting; should be 

816 either a string or ``None``. 

817 

818 :param force_create: If ``False`` (the default) then logic 

819 will first try to locate an existing setting of the same 

820 name, and update it if found, or create if not. 

821 

822 But if this param is ``True`` then logic will only try to 

823 create a new record, and not bother checking to see if it 

824 exists. 

825 

826 (Theoretically the latter offers a slight efficiency gain.) 

827 

828 :param \\**kwargs: Any remaining kwargs are ignored by the 

829 default logic, but subclass may override. 

830 """ 

831 model = self.model 

832 

833 # maybe fetch existing setting 

834 setting = None 

835 if not force_create: 

836 setting = session.get(model.Setting, name) 

837 

838 # create setting if needed 

839 if not setting: 

840 setting = model.Setting(name=name) 

841 session.add(setting) 

842 

843 # set value 

844 setting.value = value 

845 

846 def delete_setting( 

847 self, session, name, **kwargs 

848 ): # pylint: disable=unused-argument 

849 """ 

850 Delete a :term:`config setting` from the DB. 

851 

852 See also :meth:`get_setting()` and :meth:`save_setting()`. 

853 

854 :param session: Current :term:`db session`. 

855 

856 :param name: Name of the setting to delete. 

857 

858 :param \\**kwargs: Any remaining kwargs are ignored by the 

859 default logic, but subclass may override. 

860 """ 

861 model = self.model 

862 setting = session.get(model.Setting, name) 

863 if setting: 

864 session.delete(setting) 

865 

866 def continuum_is_enabled(self): 

867 """ 

868 Returns boolean indicating if Wutta-Continuum is installed and 

869 enabled. 

870 

871 Default will be ``False`` as enabling it requires additional 

872 installation and setup. For instructions see 

873 :doc:`wutta-continuum:narr/install`. 

874 """ 

875 for provider in self.providers.values(): 

876 if hasattr(provider, "continuum_is_enabled"): 

877 return provider.continuum_is_enabled() 

878 

879 return False 

880 

881 ############################## 

882 # common value renderers 

883 ############################## 

884 

885 def render_boolean(self, value): 

886 """ 

887 Render a boolean value for display. 

888 

889 :param value: A boolean, or ``None``. 

890 

891 :returns: Display string for the value. 

892 """ 

893 if value is None: 

894 return "" 

895 

896 return "Yes" if value else "No" 

897 

898 def render_currency(self, value, scale=2): 

899 """ 

900 Return a human-friendly display string for the given currency 

901 value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``. 

902 

903 :param value: Either a :class:`python:decimal.Decimal` or 

904 :class:`python:float` value. 

905 

906 :param scale: Number of decimal digits to be displayed. 

907 

908 :returns: Display string for the value. 

909 """ 

910 if value is None: 

911 return "" 

912 

913 if value < 0: 

914 fmt = f"(${{:0,.{scale}f}})" 

915 return fmt.format(0 - value) 

916 

917 fmt = f"${{:0,.{scale}f}}" 

918 return fmt.format(value) 

919 

920 display_format_date = "%Y-%m-%d" 

921 """ 

922 Format string to use when displaying :class:`python:datetime.date` 

923 objects. See also :meth:`render_date()`. 

924 """ 

925 

926 display_format_datetime = "%Y-%m-%d %H:%M%z" 

927 """ 

928 Format string to use when displaying 

929 :class:`python:datetime.datetime` objects. See also 

930 :meth:`render_datetime()`. 

931 """ 

932 

933 def render_date(self, value, local=True): 

934 """ 

935 Return a human-friendly display string for the given date. 

936 

937 Uses :attr:`display_format_date` to render the value. 

938 

939 :param value: Can be a :class:`python:datetime.date` *or* 

940 :class:`python:datetime.datetime` instance, or ``None``. 

941 

942 :param local: By default the ``value`` will first be passed to 

943 :meth:`localtime()` to normalize it for display (but only 

944 if value is a ``datetime`` instance). Specify 

945 ``local=False`` to skip that and render the value as-is. 

946 

947 :returns: Display string. 

948 """ 

949 if value is None: 

950 return "" 

951 

952 if local and isinstance(value, datetime.datetime): 

953 value = self.localtime(value) 

954 

955 return value.strftime(self.display_format_date) 

956 

957 def render_datetime(self, value, local=True, html=False): 

958 """ 

959 Return a human-friendly display string for the given datetime. 

960 

961 Uses :attr:`display_format_datetime` to render the value. 

962 

963 :param value: A :class:`python:datetime.datetime` instance (or 

964 ``None``). 

965 

966 :param local: By default the ``value`` will first be passed to 

967 :meth:`localtime()` to normalize it for display. Specify 

968 ``local=False`` to skip that and render the value as-is. 

969 

970 :param html: If true, return HTML (with tooltip showing 

971 relative time delta) instead of plain text. 

972 

973 :returns: Rendered datetime as string (or HTML with tooltip). 

974 """ 

975 if value is None: 

976 return "" 

977 

978 # we usually want to render a "local" time 

979 if local: 

980 value = self.localtime(value) 

981 

982 # simple formatted text 

983 text = value.strftime(self.display_format_datetime) 

984 

985 if html: 

986 

987 # calculate time diff 

988 # nb. if both times are naive, they should be UTC; 

989 # otherwise if both are zone-aware, this should work even 

990 # if they use different zones. 

991 delta = self.make_utc(tzinfo=bool(value.tzinfo)) - value 

992 

993 # show text w/ time diff as tooltip 

994 return HTML.tag("span", c=text, title=self.render_time_ago(delta)) 

995 

996 return text 

997 

998 def render_error(self, error): 

999 """ 

1000 Return a "human-friendly" display string for the error, e.g. 

1001 when showing it to the user. 

1002 

1003 By default, this is a convenience wrapper for 

1004 :func:`~wuttjamaican.util.simple_error()`. 

1005 """ 

1006 return simple_error(error) 

1007 

1008 def render_percent(self, value, decimals=2): 

1009 """ 

1010 Return a human-friendly display string for the given 

1011 percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``. 

1012 

1013 :param value: The value to be rendered. 

1014 

1015 :returns: Display string for the percentage value. 

1016 """ 

1017 if value is None: 

1018 return "" 

1019 fmt = f"{{:0.{decimals}f}} %" 

1020 if value < 0: 

1021 return f"({fmt.format(-value)})" 

1022 return fmt.format(value) 

1023 

1024 def render_quantity(self, value, empty_zero=False): 

1025 """ 

1026 Return a human-friendly display string for the given quantity 

1027 value, e.g. ``1.000`` becomes ``"1"``. 

1028 

1029 :param value: The quantity to be rendered. 

1030 

1031 :param empty_zero: Affects the display when value equals zero. 

1032 If false (the default), will return ``'0'``; if true then 

1033 it returns empty string. 

1034 

1035 :returns: Display string for the quantity. 

1036 """ 

1037 if value is None: 

1038 return "" 

1039 if int(value) == value: 

1040 value = int(value) 

1041 if empty_zero and value == 0: 

1042 return "" 

1043 return f"{value:,}" 

1044 return f"{value:,}".rstrip("0") 

1045 

1046 def render_time_ago(self, value): 

1047 """ 

1048 Return a human-friendly string, indicating how long ago 

1049 something occurred. 

1050 

1051 Default logic uses :func:`humanize:humanize.naturaltime()` for 

1052 the rendering. 

1053 

1054 :param value: Instance of :class:`python:datetime.datetime` or 

1055 :class:`python:datetime.timedelta`. 

1056 

1057 :returns: Text to display. 

1058 """ 

1059 # TODO: this now assumes naive UTC value incoming... 

1060 return humanize.naturaltime(value, when=self.make_utc(tzinfo=False)) 

1061 

1062 ############################## 

1063 # getters for other handlers 

1064 ############################## 

1065 

1066 def get_auth_handler(self, **kwargs): 

1067 """ 

1068 Get the configured :term:`auth handler`. 

1069 

1070 :rtype: :class:`~wuttjamaican.auth.AuthHandler` 

1071 """ 

1072 if "auth" not in self.handlers: 

1073 spec = self.config.get( 

1074 f"{self.appname}.auth.handler", default=self.default_auth_handler_spec 

1075 ) 

1076 factory = self.load_object(spec) 

1077 self.handlers["auth"] = factory(self.config, **kwargs) 

1078 return self.handlers["auth"] 

1079 

1080 def get_batch_handler(self, key, default=None, **kwargs): 

1081 """ 

1082 Get the configured :term:`batch handler` for the given type. 

1083 

1084 :param key: Unique key designating the :term:`batch type`. 

1085 

1086 :param default: Spec string to use as the default, if none is 

1087 configured. 

1088 

1089 :returns: :class:`~wuttjamaican.batch.BatchHandler` instance 

1090 for the requested type. If no spec can be determined, a 

1091 ``KeyError`` is raised. 

1092 """ 

1093 spec = self.config.get( 

1094 f"{self.appname}.batch.{key}.handler.spec", default=default 

1095 ) 

1096 if not spec: 

1097 spec = self.config.get(f"{self.appname}.batch.{key}.handler.default_spec") 

1098 if not spec: 

1099 raise KeyError(f"handler spec not found for batch key: {key}") 

1100 factory = self.load_object(spec) 

1101 return factory(self.config, **kwargs) 

1102 

1103 def get_batch_handler_specs(self, key, default=None): 

1104 """ 

1105 Get the :term:`spec` strings for all available handlers of the 

1106 given batch type. 

1107 

1108 :param key: Unique key designating the :term:`batch type`. 

1109 

1110 :param default: Default spec string(s) to include, even if not 

1111 registered. Can be a string or list of strings. 

1112 

1113 :returns: List of batch handler spec strings. 

1114 

1115 This will gather available spec strings from the following: 

1116 

1117 First, the ``default`` as provided by caller. 

1118 

1119 Second, the default spec from config, if set; for example: 

1120 

1121 .. code-block:: ini 

1122 

1123 [wutta.batch] 

1124 inventory.handler.default_spec = poser.batch.inventory:InventoryBatchHandler 

1125 

1126 Third, each spec registered via entry points. For instance in 

1127 ``pyproject.toml``: 

1128 

1129 .. code-block:: toml 

1130 

1131 [project.entry-points."wutta.batch.inventory"] 

1132 poser = "poser.batch.inventory:InventoryBatchHandler" 

1133 

1134 The final list will be "sorted" according to the above, with 

1135 the latter registered handlers being sorted alphabetically. 

1136 """ 

1137 handlers = [] 

1138 

1139 # defaults from caller 

1140 if isinstance(default, str): 

1141 handlers.append(default) 

1142 elif default: 

1143 handlers.extend(default) 

1144 

1145 # configured default, if applicable 

1146 default = self.config.get( 

1147 f"{self.config.appname}.batch.{key}.handler.default_spec" 

1148 ) 

1149 if default and default not in handlers: 

1150 handlers.append(default) 

1151 

1152 # registered via entry points 

1153 registered = [] 

1154 for handler in load_entry_points(f"{self.appname}.batch.{key}").values(): 

1155 spec = handler.get_spec() 

1156 if spec not in handlers: 

1157 registered.append(spec) 

1158 if registered: 

1159 registered.sort() 

1160 handlers.extend(registered) 

1161 

1162 return handlers 

1163 

1164 def get_db_handler(self, **kwargs): 

1165 """ 

1166 Get the configured :term:`db handler`. 

1167 

1168 :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler` 

1169 """ 

1170 if "db" not in self.handlers: 

1171 spec = self.config.get( 

1172 f"{self.appname}.db.handler", default=self.default_db_handler_spec 

1173 ) 

1174 factory = self.load_object(spec) 

1175 self.handlers["db"] = factory(self.config, **kwargs) 

1176 return self.handlers["db"] 

1177 

1178 def get_email_handler(self, **kwargs): 

1179 """ 

1180 Get the configured :term:`email handler`. 

1181 

1182 See also :meth:`send_email()`. 

1183 

1184 :rtype: :class:`~wuttjamaican.email.EmailHandler` 

1185 """ 

1186 if "email" not in self.handlers: 

1187 spec = self.config.get( 

1188 f"{self.appname}.email.handler", default=self.default_email_handler_spec 

1189 ) 

1190 factory = self.load_object(spec) 

1191 self.handlers["email"] = factory(self.config, **kwargs) 

1192 return self.handlers["email"] 

1193 

1194 def get_install_handler(self, **kwargs): 

1195 """ 

1196 Get the configured :term:`install handler`. 

1197 

1198 :rtype: :class:`~wuttjamaican.install.handler.InstallHandler` 

1199 """ 

1200 if "install" not in self.handlers: 

1201 spec = self.config.get( 

1202 f"{self.appname}.install.handler", 

1203 default=self.default_install_handler_spec, 

1204 ) 

1205 factory = self.load_object(spec) 

1206 self.handlers["install"] = factory(self.config, **kwargs) 

1207 return self.handlers["install"] 

1208 

1209 def get_people_handler(self, **kwargs): 

1210 """ 

1211 Get the configured "people" :term:`handler`. 

1212 

1213 :rtype: :class:`~wuttjamaican.people.PeopleHandler` 

1214 """ 

1215 if "people" not in self.handlers: 

1216 spec = self.config.get( 

1217 f"{self.appname}.people.handler", 

1218 default=self.default_people_handler_spec, 

1219 ) 

1220 factory = self.load_object(spec) 

1221 self.handlers["people"] = factory(self.config, **kwargs) 

1222 return self.handlers["people"] 

1223 

1224 def get_problem_handler(self, **kwargs): 

1225 """ 

1226 Get the configured :term:`problem handler`. 

1227 

1228 :rtype: :class:`~wuttjamaican.problems.ProblemHandler` 

1229 """ 

1230 if "problems" not in self.handlers: 

1231 spec = self.config.get( 

1232 f"{self.appname}.problems.handler", 

1233 default=self.default_problem_handler_spec, 

1234 ) 

1235 log.debug("problem_handler spec is: %s", spec) 

1236 factory = self.load_object(spec) 

1237 self.handlers["problems"] = factory(self.config, **kwargs) 

1238 return self.handlers["problems"] 

1239 

1240 def get_report_handler(self, **kwargs): 

1241 """ 

1242 Get the configured :term:`report handler`. 

1243 

1244 :rtype: :class:`~wuttjamaican.reports.ReportHandler` 

1245 """ 

1246 if "reports" not in self.handlers: 

1247 spec = self.config.get( 

1248 f"{self.appname}.reports.handler_spec", 

1249 default=self.default_report_handler_spec, 

1250 ) 

1251 factory = self.load_object(spec) 

1252 self.handlers["reports"] = factory(self.config, **kwargs) 

1253 return self.handlers["reports"] 

1254 

1255 ############################## 

1256 # convenience delegators 

1257 ############################## 

1258 

1259 def get_person(self, obj, **kwargs): 

1260 """ 

1261 Convenience method to locate a 

1262 :class:`~wuttjamaican.db.model.base.Person` for the given 

1263 object. 

1264 

1265 This delegates to the "people" handler method, 

1266 :meth:`~wuttjamaican.people.PeopleHandler.get_person()`. 

1267 """ 

1268 return self.get_people_handler().get_person(obj, **kwargs) 

1269 

1270 def send_email(self, *args, **kwargs): 

1271 """ 

1272 Send an email message. 

1273 

1274 This is a convenience wrapper around 

1275 :meth:`~wuttjamaican.email.EmailHandler.send_email()`. 

1276 """ 

1277 self.get_email_handler().send_email(*args, **kwargs) 

1278 

1279 

1280class AppProvider: # pylint: disable=too-few-public-methods 

1281 """ 

1282 Base class for :term:`app providers<app provider>`. 

1283 

1284 These can add arbitrary extra functionality to the main :term:`app 

1285 handler`. See also :doc:`/narr/providers/app`. 

1286 

1287 :param config: The app :term:`config object`. 

1288 

1289 ``AppProvider`` instances have the following attributes: 

1290 

1291 .. attribute:: config 

1292 

1293 Reference to the config object. 

1294 

1295 .. attribute:: app 

1296 

1297 Reference to the parent app handler. 

1298 

1299 Some things which a subclass may define, in order to register 

1300 various features with the app: 

1301 

1302 .. attribute:: email_modules 

1303 

1304 List of :term:`email modules <email module>` provided. Should 

1305 be a list of strings; each is a dotted module path, e.g.:: 

1306 

1307 email_modules = ['poser.emails'] 

1308 

1309 .. attribute:: email_templates 

1310 

1311 List of :term:`email template` folders provided. Can be a list 

1312 of paths, or a single path as string:: 

1313 

1314 email_templates = ['poser:templates/email'] 

1315 

1316 email_templates = 'poser:templates/email' 

1317 

1318 Note the syntax, which specifies python module, then colon 

1319 (``:``), then filesystem path below that. However absolute 

1320 file paths may be used as well, when applicable. 

1321 """ 

1322 

1323 def __init__(self, config): 

1324 if isinstance(config, AppHandler): 

1325 warnings.warn( 

1326 "passing app handler to app provider is deprecated; " 

1327 "must pass config object instead", 

1328 DeprecationWarning, 

1329 stacklevel=2, 

1330 ) 

1331 config = config.config 

1332 

1333 self.config = config 

1334 self.app = self.config.get_app() 

1335 

1336 @property 

1337 def appname(self): 

1338 """ 

1339 The :term:`app name` for the current app. 

1340 

1341 See also :attr:`AppHandler.appname`. 

1342 """ 

1343 return self.app.appname 

1344 

1345 

1346class GenericHandler: 

1347 """ 

1348 Generic base class for handlers. 

1349 

1350 When the :term:`app` defines a new *type* of :term:`handler` it 

1351 may subclass this when defining the handler base class. 

1352 

1353 :param config: Config object for the app. This should be an 

1354 instance of :class:`~wuttjamaican.conf.WuttaConfig`. 

1355 """ 

1356 

1357 def __init__(self, config): 

1358 self.config = config 

1359 self.app = self.config.get_app() 

1360 self.modules = {} 

1361 self.classes = {} 

1362 

1363 @property 

1364 def appname(self): 

1365 """ 

1366 The :term:`app name` for the current app. 

1367 

1368 See also :attr:`AppHandler.appname`. 

1369 """ 

1370 return self.app.appname 

1371 

1372 @classmethod 

1373 def get_spec(cls): 

1374 """ 

1375 Returns the class :term:`spec` string for the handler. 

1376 """ 

1377 return f"{cls.__module__}:{cls.__name__}" 

1378 

1379 def get_provider_modules(self, module_type): 

1380 """ 

1381 Returns a list of all available modules of the given type. 

1382 

1383 Not all handlers would need such a thing, but notable ones 

1384 which do are the :term:`email handler` and :term:`report 

1385 handler`. Both can obtain classes (emails or reports) from 

1386 arbitrary modules, and this method is used to locate them. 

1387 

1388 This will discover all modules exposed by the app 

1389 :term:`providers <provider>`, which expose an attribute with 

1390 name like ``f"{module_type}_modules"``. 

1391 

1392 :param module_type: Unique name referring to a particular 

1393 "type" of modules to locate, e.g. ``'email'``. 

1394 

1395 :returns: List of module objects. 

1396 """ 

1397 if module_type not in self.modules: 

1398 self.modules[module_type] = [] 

1399 for provider in self.app.providers.values(): 

1400 name = f"{module_type}_modules" 

1401 if hasattr(provider, name): 

1402 modules = getattr(provider, name) 

1403 if modules: 

1404 if isinstance(modules, str): 

1405 modules = [modules] 

1406 for modpath in modules: 

1407 module = importlib.import_module(modpath) 

1408 self.modules[module_type].append(module) 

1409 return self.modules[module_type]