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

374 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-07 21:12 -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 - app handler 

25""" 

26 

27# pylint: disable=too-many-lines 

28 

29import datetime 

30import logging 

31import os 

32import sys 

33import warnings 

34import importlib 

35from importlib.metadata import version 

36 

37import humanize 

38from webhelpers2.html import HTML 

39 

40from wuttjamaican.util import ( 

41 get_timezone_by_name, 

42 get_value, 

43 localtime, 

44 load_entry_points, 

45 load_object, 

46 make_title, 

47 make_full_name, 

48 make_utc, 

49 make_uuid, 

50 make_str_uuid, 

51 make_true_uuid, 

52 progress_loop, 

53 resource_path, 

54 simple_error, 

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_cleanup_handler_spec = "wuttjamaican.cleanup:CleanupHandler" 

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

110 default_email_handler_spec = "wuttjamaican.email:EmailHandler" 

111 default_install_handler_spec = "wuttjamaican.install:InstallHandler" 

112 default_people_handler_spec = "wuttjamaican.people:PeopleHandler" 

113 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler" 

114 default_report_handler_spec = "wuttjamaican.reports:ReportHandler" 

115 default_upgrade_handler_spec = "wuttjamaican.upgrades:UpgradeHandler" 

116 

117 def __init__(self, config): 

118 self.config = config 

119 self.handlers = {} 

120 self.timezones = {} 

121 

122 @property 

123 def appname(self): 

124 """ 

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

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

127 

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

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

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

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

132 basis. 

133 """ 

134 return self.config.appname 

135 

136 def __getattr__(self, name): 

137 """ 

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

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

140 

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

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

143 attribute wins, and that value is returned. 

144 

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

146 providers. 

147 """ 

148 

149 if name == "model": 

150 return self.get_model() 

151 

152 if name == "enum": 

153 return self.get_enum() 

154 

155 if name == "providers": 

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

157 return self.providers 

158 

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

160 if hasattr(provider, name): 

161 return getattr(provider, name) 

162 

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

164 

165 def get_all_providers(self): 

166 """ 

167 Load and return all registered providers. 

168 

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

170 use :attr:`providers`. 

171 

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

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

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

175 ``pyproject.toml``): 

176 

177 .. code-block:: toml 

178 

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

180 wuttaweb = "wuttaweb.app:WebAppProvider" 

181 

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

183 :class:`AppProvider` instances. 

184 """ 

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

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

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

188 for key in list(providers): 

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

190 return providers 

191 

192 def get_title(self, default=None): 

193 """ 

194 Returns the configured title for the app. 

195 

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

197 configured. 

198 

199 :returns: Title for the app. 

200 """ 

201 return self.config.get( 

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

203 ) 

204 

205 def get_node_title(self, default=None): 

206 """ 

207 Returns the configured title for the local app node. 

208 

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

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

211 

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

213 configured. 

214 

215 :returns: Title for the local app node. 

216 """ 

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

218 if title: 

219 return title 

220 return self.get_title(default=default) 

221 

222 def get_node_type(self, default=None): 

223 """ 

224 Returns the "type" of current app node. 

225 

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

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

228 case it is needed by a particular app ecosystem. 

229 

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

231 

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

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

234 so: 

235 

236 .. code-block:: ini 

237 

238 [wutta] 

239 node_type = warehouse 

240 """ 

241 return self.config.get( 

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

243 ) 

244 

245 def get_distribution(self, obj=None): 

246 """ 

247 Returns the appropriate Python distribution name. 

248 

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

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

251 object's type/class. 

252 

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

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

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

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

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

258 ``self`` instead of ``obj``. 

259 

260 In other words by default this returns the distribution to 

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

262 

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

264 

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

266 the appropriate distribution. 

267 

268 :returns: string, or ``None`` 

269 

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

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

272 PyPI for instance. 

273 

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

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

276 it in config: 

277 

278 .. code-block:: ini 

279 

280 [wutta] 

281 app_dist = My-Poser-Dist 

282 """ 

283 if obj is None: 

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

285 if dist: 

286 return dist 

287 

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

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

290 modpath = None 

291 if not modpath: 

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

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

294 

295 try: 

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

297 packages_distributions, 

298 ) 

299 except ImportError: # python < 3.10 

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

301 packages_distributions, 

302 ) 

303 

304 pkgmap = packages_distributions() 

305 if pkgname in pkgmap: 

306 dist = pkgmap[pkgname][0] 

307 return dist 

308 

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

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

311 

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

313 """ 

314 Returns the version of a given Python distribution. 

315 

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

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

318 

319 So by default this will return the version of whichever 

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

321 

322 :returns: Version as string. 

323 """ 

324 if not dist: 

325 dist = self.get_distribution(obj=obj) 

326 if dist: 

327 return version(dist) 

328 return None 

329 

330 def get_model(self): 

331 """ 

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

333 

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

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

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

337 

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

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

340 provided another default. 

341 

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

343 extension):: 

344 

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

346 """ 

347 if "model" not in self.__dict__: 

348 spec = self.config.get( 

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

350 usedb=False, 

351 default=self.default_model_spec, 

352 ) 

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

354 return self.model 

355 

356 def get_enum(self): 

357 """ 

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

359 

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

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

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

363 

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

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

366 another default. 

367 

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

369 extension):: 

370 

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

372 """ 

373 if "enum" not in self.__dict__: 

374 spec = self.config.get( 

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

376 ) 

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

378 return self.enum 

379 

380 def get_value(self, obj, key): 

381 """ 

382 Convenience wrapper around 

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

384 

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

386 have named attributes. 

387 

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

389 

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

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

392 the key/attr set. 

393 """ 

394 return get_value(obj, key) 

395 

396 def load_object(self, spec): 

397 """ 

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

399 given spec string. 

400 

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

402 

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

404 

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

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

407 given name, then an error will raise. 

408 """ 

409 return load_object(spec) 

410 

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

412 """ 

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

414 

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

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

417 

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

419 the configured path and ignore the default path. 

420 

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

422 returned path exists, creating it if necessary. 

423 

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

425 paths for the final value. 

426 

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

428 environment root:: 

429 

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

431 

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

433 """ 

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

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

436 

437 # maybe specify default path 

438 if not configured_only: 

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

440 kwargs.setdefault("default", path) 

441 

442 # get configured path 

443 kwargs.setdefault("usedb", False) 

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

445 

446 # add any subpath info 

447 if path and args: 

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

449 

450 # create path if requested/needed 

451 if create: 

452 if not path: 

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

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

455 os.makedirs(path) 

456 

457 return path 

458 

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

460 """ 

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

462 

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

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

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

466 

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

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

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

470 when this method is called. 

471 

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

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

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

475 """ 

476 appdir = path 

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

478 os.makedirs(appdir) 

479 

480 if not subfolders: 

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

482 

483 for name in subfolders: 

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

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

486 os.mkdir(path) 

487 

488 def render_mako_template( 

489 self, 

490 template, 

491 context, 

492 output_path=None, 

493 ): 

494 """ 

495 Convenience method to render a Mako template. 

496 

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

498 instance. 

499 

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

501 

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

503 written. 

504 

505 :returns: Rendered output as string. 

506 """ 

507 output = template.render(**context) 

508 if output_path: 

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

510 f.write(output) 

511 return output 

512 

513 def resource_path(self, path): 

514 """ 

515 Convenience wrapper for 

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

517 """ 

518 return resource_path(path) 

519 

520 def make_session(self, **kwargs): 

521 """ 

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

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

524 instance. 

525 

526 :returns: SQLAlchemy session for the app DB. 

527 """ 

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

529 

530 return Session(**kwargs) 

531 

532 def make_title(self, text): 

533 """ 

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

535 

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

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

538 

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

540 

541 By default this just invokes 

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

543 """ 

544 return make_title(text) 

545 

546 def make_full_name(self, *parts): 

547 """ 

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

549 

550 This is a convenience wrapper around 

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

552 """ 

553 return make_full_name(*parts) 

554 

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

556 """ 

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

558 

559 This checks config for a setting which corresponds to the 

560 given ``key``, then calls 

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

562 actual timezone object. 

563 

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

565 other keys may correspond to other configured timezones (if 

566 applicable). 

567 

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

569 is found, Python itself will determine the default system 

570 local timezone. 

571 

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

573 is found. 

574 

575 .. note:: 

576 

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

578 unwanted repetitive lookups when processing multiple 

579 datetimes etc. (Since this method is called by 

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

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

582 

583 Example config: 

584 

585 .. code-block:: ini 

586 

587 [wutta] 

588 timezone.default = America/Chicago 

589 timezone.westcoast = America/Los_Angeles 

590 

591 Example usage:: 

592 

593 tz_default = app.get_timezone() 

594 tz_westcoast = app.get_timezone("westcoast") 

595 

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

597 

598 :param key: Config key for desired timezone. 

599 

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

601 """ 

602 if key not in self.timezones: 

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

604 tzname = self.config.get(setting) 

605 if tzname: 

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

607 

608 elif key == "default": 

609 # fallback to system default 

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

611 

612 else: 

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

614 # again with require() to force error 

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

616 

617 return self.timezones[key] 

618 

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

620 """ 

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

622 timezone. 

623 

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

625 heuristics to determine the name. 

626 

627 :param key: Config key for desired timezone. 

628 

629 :returns: String name for the timezone. 

630 """ 

631 tz = self.get_timezone(key=key) 

632 try: 

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

634 # but not sure yet about dateutils.tz ? 

635 return tz.key 

636 except AttributeError: 

637 # this should work for system default fallback, afaik 

638 dt = datetime.datetime.now(tz) 

639 return dt.tzname() 

640 

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

642 """ 

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

644 

645 This is a convenience wrapper around 

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

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

648 (unless caller specifies that). 

649 

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

651 

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

653 :meth:`today()`. 

654 """ 

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

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

657 

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

659 """ 

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

661 convenience wrapper around 

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

663 

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

665 

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

667 """ 

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

669 

670 def today(self): 

671 """ 

672 Convenience method to return the current date, according 

673 to local time zone. 

674 

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

676 

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

678 """ 

679 return self.localtime().date() 

680 

681 # TODO: deprecate / remove this eventually 

682 def make_true_uuid(self): 

683 """ 

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

685 

686 This is a convenience around 

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

688 

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

690 

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

692 """ 

693 return make_true_uuid() 

694 

695 # TODO: deprecate / remove this eventually 

696 def make_str_uuid(self): 

697 """ 

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

699 

700 This is a convenience around 

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

702 

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

704 

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

706 """ 

707 return make_str_uuid() 

708 

709 # TODO: eventually refactor, to return true uuid 

710 def make_uuid(self): 

711 """ 

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

713 

714 This is a convenience around 

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

716 

717 :returns: UUID as 32-character hex string 

718 

719 .. warning:: 

720 

721 **TEMPORARY BEHAVIOR** 

722 

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

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

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

726 type you expect. 

727 

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

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

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

731 """ 

732 warnings.warn( 

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

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

735 DeprecationWarning, 

736 stacklevel=2, 

737 ) 

738 return make_uuid() 

739 

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

741 """ 

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

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

744 way. 

745 

746 This is a wrapper around 

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

748 param details. 

749 """ 

750 return progress_loop(*args, **kwargs) 

751 

752 def get_session(self, obj): 

753 """ 

754 Returns the SQLAlchemy session with which the given object is 

755 associated. Simple convenience wrapper around 

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

757 """ 

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

759 

760 return orm.object_session(obj) 

761 

762 def short_session(self, **kwargs): 

763 """ 

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

765 

766 This is a convenience wrapper around 

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

768 

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

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

771 :meth:`make_session`. 

772 """ 

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

774 

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

776 kwargs["factory"] = self.make_session 

777 

778 return short_session(**kwargs) 

779 

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

781 """ 

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

783 

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

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

786 

787 Default implementation is just a convenience wrapper around 

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

789 

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

791 

792 :param session: App DB session. 

793 

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

795 

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

797 default logic, but subclass may override. 

798 

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

800 """ 

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

802 

803 return get_setting(session, name) 

804 

805 def save_setting( 

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

807 ): # pylint: disable=unused-argument 

808 """ 

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

810 

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

812 

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

814 

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

816 

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

818 either a string or ``None``. 

819 

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

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

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

823 

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

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

826 exists. 

827 

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

829 

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

831 default logic, but subclass may override. 

832 """ 

833 model = self.model 

834 

835 # maybe fetch existing setting 

836 setting = None 

837 if not force_create: 

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

839 

840 # create setting if needed 

841 if not setting: 

842 setting = model.Setting(name=name) 

843 session.add(setting) 

844 

845 # set value 

846 setting.value = value 

847 

848 def delete_setting( 

849 self, session, name, **kwargs 

850 ): # pylint: disable=unused-argument 

851 """ 

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

853 

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

855 

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

857 

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

859 

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

861 default logic, but subclass may override. 

862 """ 

863 model = self.model 

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

865 if setting: 

866 session.delete(setting) 

867 

868 def continuum_is_enabled(self): 

869 """ 

870 Returns boolean indicating if Wutta-Continuum is installed and 

871 enabled. 

872 

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

874 installation and setup. For instructions see 

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

876 """ 

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

878 if hasattr(provider, "continuum_is_enabled"): 

879 return provider.continuum_is_enabled() 

880 

881 return False 

882 

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

884 # common value renderers 

885 ############################## 

886 

887 def render_boolean(self, value): 

888 """ 

889 Render a boolean value for display. 

890 

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

892 

893 :returns: Display string for the value. 

894 """ 

895 if value is None: 

896 return "" 

897 

898 return "Yes" if value else "No" 

899 

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

901 """ 

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

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

904 

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

906 :class:`python:float` value. 

907 

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

909 

910 :returns: Display string for the value. 

911 """ 

912 if value is None: 

913 return "" 

914 

915 if value < 0: 

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

917 return fmt.format(0 - value) 

918 

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

920 return fmt.format(value) 

921 

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

923 """ 

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

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

926 """ 

927 

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

929 """ 

930 Format string to use when displaying 

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

932 :meth:`render_datetime()`. 

933 """ 

934 

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

936 """ 

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

938 

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

940 

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

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

943 

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

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

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

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

948 

949 :returns: Display string. 

950 """ 

951 if value is None: 

952 return "" 

953 

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

955 value = self.localtime(value) 

956 

957 return value.strftime(self.display_format_date) 

958 

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

960 """ 

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

962 

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

964 

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

966 ``None``). 

967 

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

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

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

971 

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

973 relative time delta) instead of plain text. 

974 

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

976 """ 

977 if value is None: 

978 return "" 

979 

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

981 if local: 

982 value = self.localtime(value) 

983 

984 # simple formatted text 

985 text = value.strftime(self.display_format_datetime) 

986 

987 if html: 

988 

989 # calculate time diff 

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

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

992 # if they use different zones. 

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

994 

995 # show text w/ time diff as tooltip 

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

997 

998 return text 

999 

1000 def render_error(self, error): 

1001 """ 

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

1003 when showing it to the user. 

1004 

1005 By default, this is a convenience wrapper for 

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

1007 """ 

1008 return simple_error(error) 

1009 

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

1011 """ 

1012 Return a human-friendly display string for the given 

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

1014 

1015 :param value: The value to be rendered. 

1016 

1017 :returns: Display string for the percentage value. 

1018 """ 

1019 if value is None: 

1020 return "" 

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

1022 if value < 0: 

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

1024 return fmt.format(value) 

1025 

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

1027 """ 

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

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

1030 

1031 :param value: The quantity to be rendered. 

1032 

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

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

1035 it returns empty string. 

1036 

1037 :returns: Display string for the quantity. 

1038 """ 

1039 if value is None: 

1040 return "" 

1041 if int(value) == value: 

1042 value = int(value) 

1043 if empty_zero and value == 0: 

1044 return "" 

1045 return f"{value:,}" 

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

1047 

1048 def render_time_ago(self, value): 

1049 """ 

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

1051 something occurred. 

1052 

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

1054 the rendering. 

1055 

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

1057 :class:`python:datetime.timedelta`. 

1058 

1059 :returns: Text to display. 

1060 """ 

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

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

1063 

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

1065 # getters for other handlers 

1066 ############################## 

1067 

1068 def get_auth_handler(self, **kwargs): 

1069 """ 

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

1071 

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

1073 """ 

1074 if "auth" not in self.handlers: 

1075 spec = self.config.get( 

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

1077 ) 

1078 factory = self.load_object(spec) 

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

1080 return self.handlers["auth"] 

1081 

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

1083 """ 

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

1085 

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

1087 

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

1089 configured. 

1090 

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

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

1093 ``KeyError`` is raised. 

1094 """ 

1095 spec = self.config.get( 

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

1097 ) 

1098 if not spec: 

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

1100 if not spec: 

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

1102 factory = self.load_object(spec) 

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

1104 

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

1106 """ 

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

1108 given batch type. 

1109 

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

1111 

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

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

1114 

1115 :returns: List of batch handler spec strings. 

1116 

1117 This will gather available spec strings from the following: 

1118 

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

1120 

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

1122 

1123 .. code-block:: ini 

1124 

1125 [wutta.batch] 

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

1127 

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

1129 ``pyproject.toml``: 

1130 

1131 .. code-block:: toml 

1132 

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

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

1135 

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

1137 the latter registered handlers being sorted alphabetically. 

1138 """ 

1139 handlers = [] 

1140 

1141 # defaults from caller 

1142 if isinstance(default, str): 

1143 handlers.append(default) 

1144 elif default: 

1145 handlers.extend(default) 

1146 

1147 # configured default, if applicable 

1148 default = self.config.get( 

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

1150 ) 

1151 if default and default not in handlers: 

1152 handlers.append(default) 

1153 

1154 # registered via entry points 

1155 registered = [] 

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

1157 spec = handler.get_spec() 

1158 if spec not in handlers: 

1159 registered.append(spec) 

1160 if registered: 

1161 registered.sort() 

1162 handlers.extend(registered) 

1163 

1164 return handlers 

1165 

1166 def get_cleanup_handler(self): 

1167 """ 

1168 Get the configured :term:`cleanup handler`. 

1169 

1170 :rtype: :class:`~wuttjamaican.cleanup.CleanupHandler` 

1171 """ 

1172 if "cleanup" not in self.handlers: 

1173 spec = self.config.get( 

1174 f"{self.appname}.cleanup.handler", 

1175 default=self.default_cleanup_handler_spec, 

1176 ) 

1177 log.debug("cleanup handler spec is %s", spec) 

1178 factory = self.load_object(spec) 

1179 self.handlers["cleanup"] = factory(self.config) 

1180 return self.handlers["cleanup"] 

1181 

1182 def get_db_handler(self, **kwargs): 

1183 """ 

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

1185 

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

1187 """ 

1188 if "db" not in self.handlers: 

1189 spec = self.config.get( 

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

1191 ) 

1192 factory = self.load_object(spec) 

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

1194 return self.handlers["db"] 

1195 

1196 def get_email_handler(self, **kwargs): 

1197 """ 

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

1199 

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

1201 

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

1203 """ 

1204 if "email" not in self.handlers: 

1205 spec = self.config.get( 

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

1207 ) 

1208 factory = self.load_object(spec) 

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

1210 return self.handlers["email"] 

1211 

1212 def get_install_handler(self, **kwargs): 

1213 """ 

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

1215 

1216 :rtype: :class:`~wuttjamaican.install.InstallHandler` 

1217 """ 

1218 if "install" not in self.handlers: 

1219 spec = self.config.get( 

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

1221 default=self.default_install_handler_spec, 

1222 ) 

1223 factory = self.load_object(spec) 

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

1225 return self.handlers["install"] 

1226 

1227 def get_people_handler(self, **kwargs): 

1228 """ 

1229 Get the configured :term:`people handler`. 

1230 

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

1232 """ 

1233 if "people" not in self.handlers: 

1234 spec = self.config.get( 

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

1236 default=self.default_people_handler_spec, 

1237 ) 

1238 factory = self.load_object(spec) 

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

1240 return self.handlers["people"] 

1241 

1242 def get_problem_handler(self, **kwargs): 

1243 """ 

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

1245 

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

1247 """ 

1248 if "problems" not in self.handlers: 

1249 spec = self.config.get( 

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

1251 default=self.default_problem_handler_spec, 

1252 ) 

1253 log.debug("problem handler spec is %s", spec) 

1254 factory = self.load_object(spec) 

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

1256 return self.handlers["problems"] 

1257 

1258 def get_report_handler(self, **kwargs): 

1259 """ 

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

1261 

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

1263 """ 

1264 if "reports" not in self.handlers: 

1265 spec = self.config.get( 

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

1267 default=self.default_report_handler_spec, 

1268 ) 

1269 factory = self.load_object(spec) 

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

1271 return self.handlers["reports"] 

1272 

1273 def get_upgrade_handler(self, **kwargs): 

1274 """ 

1275 Get the configured :term:`upgrade handler`. 

1276 

1277 :rtype: :class:`~wuttjamaican.upgrades.UpgradeHandler` 

1278 """ 

1279 if "upgrade" not in self.handlers: 

1280 spec = self.config.get( 

1281 f"{self.appname}.upgrades.handler_spec", 

1282 default=self.default_upgrade_handler_spec, 

1283 ) 

1284 log.debug("upgrade handler spec is %s", spec) 

1285 factory = self.load_object(spec) 

1286 self.handlers["upgrade"] = factory(self.config, **kwargs) 

1287 return self.handlers["upgrade"] 

1288 

1289 ############################## 

1290 # convenience delegators 

1291 ############################## 

1292 

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

1294 """ 

1295 Convenience method to locate a 

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

1297 object. 

1298 

1299 This delegates to the "people" handler method, 

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

1301 """ 

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

1303 

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

1305 """ 

1306 Send an email message. 

1307 

1308 This is a convenience wrapper around 

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

1310 """ 

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

1312 

1313 

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

1315 """ 

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

1317 

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

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

1320 

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

1322 

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

1324 

1325 .. attribute:: config 

1326 

1327 Reference to the config object. 

1328 

1329 .. attribute:: app 

1330 

1331 Reference to the parent app handler. 

1332 

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

1334 various features with the app: 

1335 

1336 .. attribute:: email_modules 

1337 

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

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

1340 

1341 email_modules = ['poser.emails'] 

1342 

1343 .. attribute:: email_templates 

1344 

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

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

1347 

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

1349 

1350 email_templates = 'poser:templates/email' 

1351 

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

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

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

1355 """ 

1356 

1357 def __init__(self, config): 

1358 if isinstance(config, AppHandler): 

1359 warnings.warn( 

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

1361 "must pass config object instead", 

1362 DeprecationWarning, 

1363 stacklevel=2, 

1364 ) 

1365 config = config.config 

1366 

1367 self.config = config 

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

1369 

1370 @property 

1371 def appname(self): 

1372 """ 

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

1374 

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

1376 """ 

1377 return self.app.appname 

1378 

1379 

1380class GenericHandler: 

1381 """ 

1382 Generic base class for handlers. 

1383 

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

1385 may subclass this when defining the handler base class. 

1386 

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

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

1389 """ 

1390 

1391 def __init__(self, config): 

1392 self.config = config 

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

1394 self.modules = {} 

1395 self.classes = {} 

1396 

1397 @property 

1398 def appname(self): 

1399 """ 

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

1401 

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

1403 """ 

1404 return self.app.appname 

1405 

1406 @classmethod 

1407 def get_spec(cls): 

1408 """ 

1409 :returns: The class :term:`spec` string for the handler. 

1410 """ 

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

1412 

1413 def get_provider_modules(self, module_type): 

1414 """ 

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

1416 

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

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

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

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

1421 

1422 This will discover all modules exposed by the app 

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

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

1425 

1426 :param module_type: Unique name referring to a particular 

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

1428 

1429 :returns: List of module objects. 

1430 """ 

1431 if module_type not in self.modules: 

1432 self.modules[module_type] = [] 

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

1434 name = f"{module_type}_modules" 

1435 if hasattr(provider, name): 

1436 modules = getattr(provider, name) 

1437 if modules: 

1438 if isinstance(modules, str): 

1439 modules = [modules] 

1440 for modpath in modules: 

1441 module = importlib.import_module(modpath) 

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

1443 return self.modules[module_type]