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

354 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-01-06 22:51 -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 localtime, 

42 load_entry_points, 

43 load_object, 

44 make_title, 

45 make_full_name, 

46 make_utc, 

47 make_uuid, 

48 make_str_uuid, 

49 make_true_uuid, 

50 progress_loop, 

51 resource_path, 

52 simple_error, 

53) 

54 

55 

56log = logging.getLogger(__name__) 

57 

58 

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

60 """ 

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

62 handler`. 

63 

64 aka. "the handler to handle all handlers" 

65 

66 aka. "one handler to bind them all" 

67 

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

69 

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

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

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

73 

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

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

76 

77 .. attribute:: model 

78 

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

80 

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

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

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

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

85 happen automatically. 

86 

87 .. attribute:: enum 

88 

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

90 

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

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

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

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

95 happen automatically. 

96 

97 .. attribute:: providers 

98 

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

100 :meth:`get_all_providers()`. 

101 """ 

102 

103 default_app_title = "WuttJamaican" 

104 default_model_spec = "wuttjamaican.db.model" 

105 default_enum_spec = "wuttjamaican.enum" 

106 default_auth_handler_spec = "wuttjamaican.auth:AuthHandler" 

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

108 default_email_handler_spec = "wuttjamaican.email:EmailHandler" 

109 default_install_handler_spec = "wuttjamaican.install:InstallHandler" 

110 default_people_handler_spec = "wuttjamaican.people:PeopleHandler" 

111 default_problem_handler_spec = "wuttjamaican.problems:ProblemHandler" 

112 default_report_handler_spec = "wuttjamaican.reports:ReportHandler" 

113 

114 def __init__(self, config): 

115 self.config = config 

116 self.handlers = {} 

117 self.timezones = {} 

118 

119 @property 

120 def appname(self): 

121 """ 

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

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

124 

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

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

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

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

129 basis. 

130 """ 

131 return self.config.appname 

132 

133 def __getattr__(self, name): 

134 """ 

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

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

137 

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

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

140 attribute wins, and that value is returned. 

141 

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

143 providers. 

144 """ 

145 

146 if name == "model": 

147 return self.get_model() 

148 

149 if name == "enum": 

150 return self.get_enum() 

151 

152 if name == "providers": 

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

154 return self.providers 

155 

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

157 if hasattr(provider, name): 

158 return getattr(provider, name) 

159 

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

161 

162 def get_all_providers(self): 

163 """ 

164 Load and return all registered providers. 

165 

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

167 use :attr:`providers`. 

168 

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

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

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

172 ``pyproject.toml``): 

173 

174 .. code-block:: toml 

175 

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

177 wuttaweb = "wuttaweb.app:WebAppProvider" 

178 

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

180 :class:`AppProvider` instances. 

181 """ 

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

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

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

185 for key in list(providers): 

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

187 return providers 

188 

189 def get_title(self, default=None): 

190 """ 

191 Returns the configured title for the app. 

192 

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

194 configured. 

195 

196 :returns: Title for the app. 

197 """ 

198 return self.config.get( 

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

200 ) 

201 

202 def get_node_title(self, default=None): 

203 """ 

204 Returns the configured title for the local app node. 

205 

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

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

208 

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

210 configured. 

211 

212 :returns: Title for the local app node. 

213 """ 

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

215 if title: 

216 return title 

217 return self.get_title(default=default) 

218 

219 def get_node_type(self, default=None): 

220 """ 

221 Returns the "type" of current app node. 

222 

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

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

225 case it is needed by a particular app ecosystem. 

226 

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

228 

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

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

231 so: 

232 

233 .. code-block:: ini 

234 

235 [wutta] 

236 node_type = warehouse 

237 """ 

238 return self.config.get( 

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

240 ) 

241 

242 def get_distribution(self, obj=None): 

243 """ 

244 Returns the appropriate Python distribution name. 

245 

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

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

248 object's type/class. 

249 

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

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

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

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

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

255 ``self`` instead of ``obj``. 

256 

257 In other words by default this returns the distribution to 

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

259 

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

261 

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

263 the appropriate distribution. 

264 

265 :returns: string, or ``None`` 

266 

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

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

269 PyPI for instance. 

270 

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

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

273 it in config: 

274 

275 .. code-block:: ini 

276 

277 [wutta] 

278 app_dist = My-Poser-Dist 

279 """ 

280 if obj is None: 

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

282 if dist: 

283 return dist 

284 

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

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

287 modpath = None 

288 if not modpath: 

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

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

291 

292 try: 

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

294 packages_distributions, 

295 ) 

296 except ImportError: # python < 3.10 

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

298 packages_distributions, 

299 ) 

300 

301 pkgmap = packages_distributions() 

302 if pkgname in pkgmap: 

303 dist = pkgmap[pkgname][0] 

304 return dist 

305 

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

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

308 

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

310 """ 

311 Returns the version of a given Python distribution. 

312 

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

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

315 

316 So by default this will return the version of whichever 

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

318 

319 :returns: Version as string. 

320 """ 

321 if not dist: 

322 dist = self.get_distribution(obj=obj) 

323 if dist: 

324 return version(dist) 

325 return None 

326 

327 def get_model(self): 

328 """ 

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

330 

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

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

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

334 

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

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

337 provided another default. 

338 

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

340 extension):: 

341 

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

343 """ 

344 if "model" not in self.__dict__: 

345 spec = self.config.get( 

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

347 usedb=False, 

348 default=self.default_model_spec, 

349 ) 

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

351 return self.model 

352 

353 def get_enum(self): 

354 """ 

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

356 

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

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

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

360 

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

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

363 another default. 

364 

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

366 extension):: 

367 

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

369 """ 

370 if "enum" not in self.__dict__: 

371 spec = self.config.get( 

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

373 ) 

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

375 return self.enum 

376 

377 def load_object(self, spec): 

378 """ 

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

380 given spec string. 

381 

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

383 

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

385 

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

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

388 given name, then an error will raise. 

389 """ 

390 return load_object(spec) 

391 

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

393 """ 

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

395 

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

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

398 

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

400 the configured path and ignore the default path. 

401 

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

403 returned path exists, creating it if necessary. 

404 

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

406 paths for the final value. 

407 

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

409 environment root:: 

410 

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

412 

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

414 """ 

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

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

417 

418 # maybe specify default path 

419 if not configured_only: 

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

421 kwargs.setdefault("default", path) 

422 

423 # get configured path 

424 kwargs.setdefault("usedb", False) 

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

426 

427 # add any subpath info 

428 if path and args: 

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

430 

431 # create path if requested/needed 

432 if create: 

433 if not path: 

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

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

436 os.makedirs(path) 

437 

438 return path 

439 

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

441 """ 

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

443 

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

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

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

447 

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

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

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

451 when this method is called. 

452 

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

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

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

456 """ 

457 appdir = path 

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

459 os.makedirs(appdir) 

460 

461 if not subfolders: 

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

463 

464 for name in subfolders: 

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

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

467 os.mkdir(path) 

468 

469 def render_mako_template( 

470 self, 

471 template, 

472 context, 

473 output_path=None, 

474 ): 

475 """ 

476 Convenience method to render a Mako template. 

477 

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

479 instance. 

480 

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

482 

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

484 written. 

485 

486 :returns: Rendered output as string. 

487 """ 

488 output = template.render(**context) 

489 if output_path: 

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

491 f.write(output) 

492 return output 

493 

494 def resource_path(self, path): 

495 """ 

496 Convenience wrapper for 

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

498 """ 

499 return resource_path(path) 

500 

501 def make_session(self, **kwargs): 

502 """ 

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

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

505 instance. 

506 

507 :returns: SQLAlchemy session for the app DB. 

508 """ 

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

510 

511 return Session(**kwargs) 

512 

513 def make_title(self, text): 

514 """ 

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

516 

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

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

519 

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

521 

522 By default this just invokes 

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

524 """ 

525 return make_title(text) 

526 

527 def make_full_name(self, *parts): 

528 """ 

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

530 

531 This is a convenience wrapper around 

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

533 """ 

534 return make_full_name(*parts) 

535 

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

537 """ 

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

539 

540 This checks config for a setting which corresponds to the 

541 given ``key``, then calls 

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

543 actual timezone object. 

544 

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

546 other keys may correspond to other configured timezones (if 

547 applicable). 

548 

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

550 is found, Python itself will determine the default system 

551 local timezone. 

552 

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

554 is found. 

555 

556 .. note:: 

557 

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

559 unwanted repetitive lookups when processing multiple 

560 datetimes etc. (Since this method is called by 

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

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

563 

564 Example config: 

565 

566 .. code-block:: ini 

567 

568 [wutta] 

569 timezone.default = America/Chicago 

570 timezone.westcoast = America/Los_Angeles 

571 

572 Example usage:: 

573 

574 tz_default = app.get_timezone() 

575 tz_westcoast = app.get_timezone("westcoast") 

576 

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

578 

579 :param key: Config key for desired timezone. 

580 

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

582 """ 

583 if key not in self.timezones: 

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

585 tzname = self.config.get(setting) 

586 if tzname: 

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

588 

589 elif key == "default": 

590 # fallback to system default 

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

592 

593 else: 

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

595 # again with require() to force error 

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

597 

598 return self.timezones[key] 

599 

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

601 """ 

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

603 timezone. 

604 

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

606 heuristics to determine the name. 

607 

608 :param key: Config key for desired timezone. 

609 

610 :returns: String name for the timezone. 

611 """ 

612 tz = self.get_timezone(key=key) 

613 try: 

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

615 # but not sure yet about dateutils.tz ? 

616 return tz.key 

617 except AttributeError: 

618 # this should work for system default fallback, afaik 

619 dt = datetime.datetime.now(tz) 

620 return dt.tzname() 

621 

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

623 """ 

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

625 

626 This is a convenience wrapper around 

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

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

629 (unless caller specifies that). 

630 

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

632 

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

634 :meth:`today()`. 

635 """ 

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

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

638 

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

640 """ 

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

642 convenience wrapper around 

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

644 

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

646 

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

648 """ 

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

650 

651 def today(self): 

652 """ 

653 Convenience method to return the current date, according 

654 to local time zone. 

655 

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

657 

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

659 """ 

660 return self.localtime().date() 

661 

662 # TODO: deprecate / remove this eventually 

663 def make_true_uuid(self): 

664 """ 

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

666 

667 This is a convenience around 

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

669 

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

671 

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

673 """ 

674 return make_true_uuid() 

675 

676 # TODO: deprecate / remove this eventually 

677 def make_str_uuid(self): 

678 """ 

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

680 

681 This is a convenience around 

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

683 

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

685 

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

687 """ 

688 return make_str_uuid() 

689 

690 # TODO: eventually refactor, to return true uuid 

691 def make_uuid(self): 

692 """ 

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

694 

695 This is a convenience around 

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

697 

698 :returns: UUID as 32-character hex string 

699 

700 .. warning:: 

701 

702 **TEMPORARY BEHAVIOR** 

703 

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

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

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

707 type you expect. 

708 

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

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

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

712 """ 

713 warnings.warn( 

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

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

716 DeprecationWarning, 

717 stacklevel=2, 

718 ) 

719 return make_uuid() 

720 

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

722 """ 

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

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

725 way. 

726 

727 This is a wrapper around 

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

729 param details. 

730 """ 

731 return progress_loop(*args, **kwargs) 

732 

733 def get_session(self, obj): 

734 """ 

735 Returns the SQLAlchemy session with which the given object is 

736 associated. Simple convenience wrapper around 

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

738 """ 

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

740 

741 return orm.object_session(obj) 

742 

743 def short_session(self, **kwargs): 

744 """ 

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

746 

747 This is a convenience wrapper around 

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

749 

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

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

752 :meth:`make_session`. 

753 """ 

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

755 

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

757 kwargs["factory"] = self.make_session 

758 

759 return short_session(**kwargs) 

760 

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

762 """ 

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

764 

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

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

767 

768 Default implementation is just a convenience wrapper around 

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

770 

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

772 

773 :param session: App DB session. 

774 

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

776 

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

778 default logic, but subclass may override. 

779 

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

781 """ 

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

783 

784 return get_setting(session, name) 

785 

786 def save_setting( 

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

788 ): # pylint: disable=unused-argument 

789 """ 

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

791 

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

793 

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

795 

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

797 

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

799 either a string or ``None``. 

800 

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

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

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

804 

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

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

807 exists. 

808 

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

810 

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

812 default logic, but subclass may override. 

813 """ 

814 model = self.model 

815 

816 # maybe fetch existing setting 

817 setting = None 

818 if not force_create: 

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

820 

821 # create setting if needed 

822 if not setting: 

823 setting = model.Setting(name=name) 

824 session.add(setting) 

825 

826 # set value 

827 setting.value = value 

828 

829 def delete_setting( 

830 self, session, name, **kwargs 

831 ): # pylint: disable=unused-argument 

832 """ 

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

834 

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

836 

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

838 

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

840 

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

842 default logic, but subclass may override. 

843 """ 

844 model = self.model 

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

846 if setting: 

847 session.delete(setting) 

848 

849 def continuum_is_enabled(self): 

850 """ 

851 Returns boolean indicating if Wutta-Continuum is installed and 

852 enabled. 

853 

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

855 installation and setup. For instructions see 

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

857 """ 

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

859 if hasattr(provider, "continuum_is_enabled"): 

860 return provider.continuum_is_enabled() 

861 

862 return False 

863 

864 ############################## 

865 # common value renderers 

866 ############################## 

867 

868 def render_boolean(self, value): 

869 """ 

870 Render a boolean value for display. 

871 

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

873 

874 :returns: Display string for the value. 

875 """ 

876 if value is None: 

877 return "" 

878 

879 return "Yes" if value else "No" 

880 

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

882 """ 

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

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

885 

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

887 :class:`python:float` value. 

888 

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

890 

891 :returns: Display string for the value. 

892 """ 

893 if value is None: 

894 return "" 

895 

896 if value < 0: 

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

898 return fmt.format(0 - value) 

899 

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

901 return fmt.format(value) 

902 

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

904 """ 

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

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

907 """ 

908 

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

910 """ 

911 Format string to use when displaying 

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

913 :meth:`render_datetime()`. 

914 """ 

915 

916 def render_date(self, value): 

917 """ 

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

919 

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

921 

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

923 ``None``). 

924 

925 :returns: Display string. 

926 """ 

927 if value is None: 

928 return "" 

929 return value.strftime(self.display_format_date) 

930 

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

932 """ 

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

934 

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

936 

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

938 ``None``). 

939 

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

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

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

943 

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

945 relative time delta) instead of plain text. 

946 

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

948 """ 

949 if value is None: 

950 return "" 

951 

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

953 if local: 

954 value = self.localtime(value) 

955 

956 # simple formatted text 

957 text = value.strftime(self.display_format_datetime) 

958 

959 if html: 

960 

961 # calculate time diff 

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

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

964 # if they use different zones. 

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

966 

967 # show text w/ time diff as tooltip 

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

969 

970 return text 

971 

972 def render_error(self, error): 

973 """ 

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

975 when showing it to the user. 

976 

977 By default, this is a convenience wrapper for 

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

979 """ 

980 return simple_error(error) 

981 

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

983 """ 

984 Return a human-friendly display string for the given 

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

986 

987 :param value: The value to be rendered. 

988 

989 :returns: Display string for the percentage value. 

990 """ 

991 if value is None: 

992 return "" 

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

994 if value < 0: 

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

996 return fmt.format(value) 

997 

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

999 """ 

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

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

1002 

1003 :param value: The quantity to be rendered. 

1004 

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

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

1007 it returns empty string. 

1008 

1009 :returns: Display string for the quantity. 

1010 """ 

1011 if value is None: 

1012 return "" 

1013 if int(value) == value: 

1014 value = int(value) 

1015 if empty_zero and value == 0: 

1016 return "" 

1017 return f"{value:,}" 

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

1019 

1020 def render_time_ago(self, value): 

1021 """ 

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

1023 something occurred. 

1024 

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

1026 the rendering. 

1027 

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

1029 :class:`python:datetime.timedelta`. 

1030 

1031 :returns: Text to display. 

1032 """ 

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

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

1035 

1036 ############################## 

1037 # getters for other handlers 

1038 ############################## 

1039 

1040 def get_auth_handler(self, **kwargs): 

1041 """ 

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

1043 

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

1045 """ 

1046 if "auth" not in self.handlers: 

1047 spec = self.config.get( 

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

1049 ) 

1050 factory = self.load_object(spec) 

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

1052 return self.handlers["auth"] 

1053 

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

1055 """ 

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

1057 

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

1059 

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

1061 configured. 

1062 

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

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

1065 ``KeyError`` is raised. 

1066 """ 

1067 spec = self.config.get( 

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

1069 ) 

1070 if not spec: 

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

1072 if not spec: 

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

1074 factory = self.load_object(spec) 

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

1076 

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

1078 """ 

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

1080 given batch type. 

1081 

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

1083 

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

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

1086 

1087 :returns: List of batch handler spec strings. 

1088 

1089 This will gather available spec strings from the following: 

1090 

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

1092 

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

1094 

1095 .. code-block:: ini 

1096 

1097 [wutta.batch] 

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

1099 

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

1101 ``pyproject.toml``: 

1102 

1103 .. code-block:: toml 

1104 

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

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

1107 

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

1109 the latter registered handlers being sorted alphabetically. 

1110 """ 

1111 handlers = [] 

1112 

1113 # defaults from caller 

1114 if isinstance(default, str): 

1115 handlers.append(default) 

1116 elif default: 

1117 handlers.extend(default) 

1118 

1119 # configured default, if applicable 

1120 default = self.config.get( 

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

1122 ) 

1123 if default and default not in handlers: 

1124 handlers.append(default) 

1125 

1126 # registered via entry points 

1127 registered = [] 

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

1129 spec = handler.get_spec() 

1130 if spec not in handlers: 

1131 registered.append(spec) 

1132 if registered: 

1133 registered.sort() 

1134 handlers.extend(registered) 

1135 

1136 return handlers 

1137 

1138 def get_db_handler(self, **kwargs): 

1139 """ 

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

1141 

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

1143 """ 

1144 if "db" not in self.handlers: 

1145 spec = self.config.get( 

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

1147 ) 

1148 factory = self.load_object(spec) 

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

1150 return self.handlers["db"] 

1151 

1152 def get_email_handler(self, **kwargs): 

1153 """ 

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

1155 

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

1157 

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

1159 """ 

1160 if "email" not in self.handlers: 

1161 spec = self.config.get( 

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

1163 ) 

1164 factory = self.load_object(spec) 

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

1166 return self.handlers["email"] 

1167 

1168 def get_install_handler(self, **kwargs): 

1169 """ 

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

1171 

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

1173 """ 

1174 if "install" not in self.handlers: 

1175 spec = self.config.get( 

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

1177 default=self.default_install_handler_spec, 

1178 ) 

1179 factory = self.load_object(spec) 

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

1181 return self.handlers["install"] 

1182 

1183 def get_people_handler(self, **kwargs): 

1184 """ 

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

1186 

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

1188 """ 

1189 if "people" not in self.handlers: 

1190 spec = self.config.get( 

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

1192 default=self.default_people_handler_spec, 

1193 ) 

1194 factory = self.load_object(spec) 

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

1196 return self.handlers["people"] 

1197 

1198 def get_problem_handler(self, **kwargs): 

1199 """ 

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

1201 

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

1203 """ 

1204 if "problems" not in self.handlers: 

1205 spec = self.config.get( 

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

1207 default=self.default_problem_handler_spec, 

1208 ) 

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

1210 factory = self.load_object(spec) 

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

1212 return self.handlers["problems"] 

1213 

1214 def get_report_handler(self, **kwargs): 

1215 """ 

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

1217 

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

1219 """ 

1220 if "reports" not in self.handlers: 

1221 spec = self.config.get( 

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

1223 default=self.default_report_handler_spec, 

1224 ) 

1225 factory = self.load_object(spec) 

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

1227 return self.handlers["reports"] 

1228 

1229 ############################## 

1230 # convenience delegators 

1231 ############################## 

1232 

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

1234 """ 

1235 Convenience method to locate a 

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

1237 object. 

1238 

1239 This delegates to the "people" handler method, 

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

1241 """ 

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

1243 

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

1245 """ 

1246 Send an email message. 

1247 

1248 This is a convenience wrapper around 

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

1250 """ 

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

1252 

1253 

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

1255 """ 

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

1257 

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

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

1260 

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

1262 

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

1264 

1265 .. attribute:: config 

1266 

1267 Reference to the config object. 

1268 

1269 .. attribute:: app 

1270 

1271 Reference to the parent app handler. 

1272 

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

1274 various features with the app: 

1275 

1276 .. attribute:: email_modules 

1277 

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

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

1280 

1281 email_modules = ['poser.emails'] 

1282 

1283 .. attribute:: email_templates 

1284 

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

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

1287 

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

1289 

1290 email_templates = 'poser:templates/email' 

1291 

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

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

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

1295 """ 

1296 

1297 def __init__(self, config): 

1298 if isinstance(config, AppHandler): 

1299 warnings.warn( 

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

1301 "must pass config object instead", 

1302 DeprecationWarning, 

1303 stacklevel=2, 

1304 ) 

1305 config = config.config 

1306 

1307 self.config = config 

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

1309 

1310 @property 

1311 def appname(self): 

1312 """ 

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

1314 

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

1316 """ 

1317 return self.app.appname 

1318 

1319 

1320class GenericHandler: 

1321 """ 

1322 Generic base class for handlers. 

1323 

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

1325 may subclass this when defining the handler base class. 

1326 

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

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

1329 """ 

1330 

1331 def __init__(self, config): 

1332 self.config = config 

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

1334 self.modules = {} 

1335 self.classes = {} 

1336 

1337 @property 

1338 def appname(self): 

1339 """ 

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

1341 

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

1343 """ 

1344 return self.app.appname 

1345 

1346 @classmethod 

1347 def get_spec(cls): 

1348 """ 

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

1350 """ 

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

1352 

1353 def get_provider_modules(self, module_type): 

1354 """ 

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

1356 

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

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

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

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

1361 

1362 This will discover all modules exposed by the app 

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

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

1365 

1366 :param module_type: Unique name referring to a particular 

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

1368 

1369 :returns: List of module objects. 

1370 """ 

1371 if module_type not in self.modules: 

1372 self.modules[module_type] = [] 

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

1374 name = f"{module_type}_modules" 

1375 if hasattr(provider, name): 

1376 modules = getattr(provider, name) 

1377 if modules: 

1378 if isinstance(modules, str): 

1379 modules = [modules] 

1380 for modpath in modules: 

1381 module = importlib.import_module(modpath) 

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

1383 return self.modules[module_type]