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

294 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2025-01-15 17:02 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24WuttJamaican - app handler 

25""" 

26 

27import datetime 

28import importlib 

29import os 

30import sys 

31import warnings 

32 

33import humanize 

34 

35from wuttjamaican.util import (load_entry_points, load_object, 

36 make_title, make_full_name, make_uuid, make_true_uuid, 

37 progress_loop, resource_path, simple_error) 

38 

39 

40class AppHandler: 

41 """ 

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

43 handler`. 

44 

45 aka. "the handler to handle all handlers" 

46 

47 aka. "one handler to bind them all" 

48 

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

50 

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

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

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

54 

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

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

57 

58 .. attribute:: model 

59 

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

61 

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

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

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

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

66 happen automatically. 

67 

68 .. attribute:: enum 

69 

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

71 

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

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

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

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

76 happen automatically. 

77 

78 .. attribute:: providers 

79 

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

81 :meth:`get_all_providers()`. 

82 """ 

83 default_app_title = "WuttJamaican" 

84 default_model_spec = 'wuttjamaican.db.model' 

85 default_enum_spec = 'wuttjamaican.enum' 

86 default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler' 

87 default_db_handler_spec = 'wuttjamaican.db.handler:DatabaseHandler' 

88 default_email_handler_spec = 'wuttjamaican.email:EmailHandler' 

89 default_install_handler_spec = 'wuttjamaican.install:InstallHandler' 

90 default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' 

91 default_report_handler_spec = 'wuttjamaican.reports:ReportHandler' 

92 

93 def __init__(self, config): 

94 self.config = config 

95 self.handlers = {} 

96 

97 @property 

98 def appname(self): 

99 """ 

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

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

102 

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

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

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

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

107 basis. 

108 """ 

109 return self.config.appname 

110 

111 def __getattr__(self, name): 

112 """ 

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

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

115 

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

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

118 attribute wins, and that value is returned. 

119 

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

121 providers. 

122 """ 

123 

124 if name == 'model': 

125 return self.get_model() 

126 

127 if name == 'enum': 

128 return self.get_enum() 

129 

130 if name == 'providers': 

131 self.providers = self.get_all_providers() 

132 return self.providers 

133 

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

135 if hasattr(provider, name): 

136 return getattr(provider, name) 

137 

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

139 

140 def get_all_providers(self): 

141 """ 

142 Load and return all registered providers. 

143 

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

145 use :attr:`providers`. 

146 

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

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

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

150 ``pyproject.toml``): 

151 

152 .. code-block:: toml 

153 

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

155 wuttaweb = "wuttaweb.app:WebAppProvider" 

156 

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

158 :class:`AppProvider` instances. 

159 """ 

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

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

162 providers = load_entry_points('wutta.app.providers') 

163 for key in list(providers): 

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

165 return providers 

166 

167 def get_title(self, default=None): 

168 """ 

169 Returns the configured title for the app. 

170 

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

172 configured. 

173 

174 :returns: Title for the app. 

175 """ 

176 return self.config.get(f'{self.appname}.app_title', 

177 default=default or self.default_app_title) 

178 

179 def get_node_title(self, default=None): 

180 """ 

181 Returns the configured title for the local app node. 

182 

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

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

185 

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

187 configured. 

188 

189 :returns: Title for the local app node. 

190 """ 

191 title = self.config.get(f'{self.appname}.node_title') 

192 if title: 

193 return title 

194 return self.get_title(default=default) 

195 

196 def get_node_type(self, default=None): 

197 """ 

198 Returns the "type" of current app node. 

199 

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

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

202 case it is needed by a particular app ecosystem. 

203 

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

205 

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

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

208 so: 

209 

210 .. code-block:: ini 

211 

212 [wutta] 

213 node_type = warehouse 

214 """ 

215 return self.config.get(f'{self.appname}.node_type', default=default, 

216 usedb=False) 

217 

218 def get_distribution(self, obj=None): 

219 """ 

220 Returns the appropriate Python distribution name. 

221 

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

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

224 object's type/class. 

225 

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

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

228 ``wutta.app_dist`` (or similar, dpending on :attr:`appname`). 

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

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

231 ``self`` instead of ``obj``. 

232 

233 In other words by default this returns the distribution to 

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

235 

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

237 

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

239 the appropriate distribution. 

240 

241 :returns: string, or ``None`` 

242 

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

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

245 PyPI for instance. 

246 

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

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

249 it in config: 

250 

251 .. code-block:: ini 

252 

253 [wutta] 

254 app_dist = My-Poser-Dist 

255 """ 

256 if obj is None: 

257 dist = self.config.get(f'{self.appname}.app_dist') 

258 if dist: 

259 return dist 

260 

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

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

263 modpath = None 

264 if not modpath: 

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

266 pkgname = modpath.split('.')[0] 

267 

268 try: 

269 from importlib.metadata import packages_distributions 

270 except ImportError: # python < 3.10 

271 from importlib_metadata import packages_distributions 

272 

273 pkgmap = packages_distributions() 

274 if pkgname in pkgmap: 

275 dist = pkgmap[pkgname][0] 

276 return dist 

277 

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

279 if obj is not None: 

280 return self.config.get(f'{self.appname}.app_dist') 

281 

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

283 """ 

284 Returns the version of a given Python distribution. 

285 

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

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

288 

289 So by default this will return the version of whichever 

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

291 

292 :returns: Version as string. 

293 """ 

294 from importlib.metadata import version 

295 

296 if not dist: 

297 dist = self.get_distribution(obj=obj) 

298 if dist: 

299 return version(dist) 

300 

301 def get_model(self): 

302 """ 

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

304 

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

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

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

308 

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

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

311 provided another default. 

312 

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

314 extension):: 

315 

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

317 """ 

318 if 'model' not in self.__dict__: 

319 spec = self.config.get(f'{self.appname}.model_spec', 

320 usedb=False, 

321 default=self.default_model_spec) 

322 self.model = importlib.import_module(spec) 

323 return self.model 

324 

325 def get_enum(self): 

326 """ 

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

328 

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

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

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

332 

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

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

335 another default. 

336 

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

338 extension):: 

339 

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

341 """ 

342 if 'enum' not in self.__dict__: 

343 spec = self.config.get(f'{self.appname}.enum_spec', 

344 usedb=False, 

345 default=self.default_enum_spec) 

346 self.enum = importlib.import_module(spec) 

347 return self.enum 

348 

349 def load_object(self, spec): 

350 """ 

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

352 given spec string. 

353 

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

355 

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

357 

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

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

360 given name, then an error will raise. 

361 """ 

362 return load_object(spec) 

363 

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

365 """ 

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

367 

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

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

370 

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

372 the configured path and ignore the default path. 

373 

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

375 returned path exists, creating it if necessary. 

376 

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

378 paths for the final value. 

379 

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

381 environment root:: 

382 

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

384 

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

386 """ 

387 configured_only = kwargs.pop('configured_only', False) 

388 create = kwargs.pop('create', False) 

389 

390 # maybe specify default path 

391 if not configured_only: 

392 path = os.path.join(sys.prefix, 'app') 

393 kwargs.setdefault('default', path) 

394 

395 # get configured path 

396 kwargs.setdefault('usedb', False) 

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

398 

399 # add any subpath info 

400 if path and args: 

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

402 

403 # create path if requested/needed 

404 if create: 

405 if not path: 

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

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

408 os.makedirs(path) 

409 

410 return path 

411 

412 def make_appdir(self, path, subfolders=None, **kwargs): 

413 """ 

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

415 

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

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

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

419 

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

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

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

423 when this method is called. 

424 

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

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

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

428 """ 

429 appdir = path 

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

431 os.makedirs(appdir) 

432 

433 if not subfolders: 

434 subfolders = ['cache', 'data', 'log', 'work'] 

435 

436 for name in subfolders: 

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

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

439 os.mkdir(path) 

440 

441 def render_mako_template( 

442 self, 

443 template, 

444 context, 

445 output_path=None, 

446 ): 

447 """ 

448 Convenience method to render a Mako template. 

449 

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

451 instance. 

452 

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

454 

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

456 written. 

457 

458 :returns: Rendered output as string. 

459 """ 

460 output = template.render(**context) 

461 if output_path: 

462 with open(output_path, 'wt') as f: 

463 f.write(output) 

464 return output 

465 

466 def resource_path(self, path): 

467 """ 

468 Convenience wrapper for 

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

470 """ 

471 return resource_path(path) 

472 

473 def make_session(self, **kwargs): 

474 """ 

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

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

477 instance. 

478 

479 :returns: SQLAlchemy session for the app DB. 

480 """ 

481 from .db import Session 

482 

483 return Session(**kwargs) 

484 

485 def make_title(self, text, **kwargs): 

486 """ 

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

488 

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

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

491 

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

493 

494 By default this just invokes 

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

496 """ 

497 return make_title(text) 

498 

499 def make_full_name(self, *parts): 

500 """ 

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

502 

503 This is a convenience wrapper around 

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

505 """ 

506 return make_full_name(*parts) 

507 

508 def make_true_uuid(self): 

509 """ 

510 Generate a new UUID value. 

511 

512 By default this simply calls 

513 :func:`wuttjamaican.util.make_true_uuid()`. 

514 

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

516 

517 .. warning:: 

518 

519 For now, callers should use this method when they want a 

520 proper UUID instance, whereas :meth:`make_uuid()` will 

521 always return a string. 

522 

523 However once all dependent logic has been refactored to 

524 support proper UUID data type, then ``make_uuid()`` will 

525 return those and this method will eventually be removed. 

526 """ 

527 return make_true_uuid() 

528 

529 def make_uuid(self): 

530 """ 

531 Generate a new UUID value. 

532 

533 By default this simply calls 

534 :func:`wuttjamaican.util.make_uuid()`. 

535 

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

537 

538 .. warning:: 

539 

540 For now, this method always returns a string. 

541 

542 However once all dependent logic has been refactored to 

543 support proper UUID data type, then this method will return 

544 those and the :meth:`make_true_uuid()` method will 

545 eventually be removed. 

546 """ 

547 return make_uuid() 

548 

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

550 """ 

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

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

553 way. 

554 

555 This is a wrapper around 

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

557 param details. 

558 """ 

559 return progress_loop(*args, **kwargs) 

560 

561 def get_session(self, obj): 

562 """ 

563 Returns the SQLAlchemy session with which the given object is 

564 associated. Simple convenience wrapper around 

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

566 """ 

567 from sqlalchemy import orm 

568 

569 return orm.object_session(obj) 

570 

571 def short_session(self, **kwargs): 

572 """ 

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

574 

575 This is a convenience wrapper around 

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

577 

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

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

580 :meth:`make_session`. 

581 """ 

582 from .db import short_session 

583 

584 if 'factory' not in kwargs and 'config' not in kwargs: 

585 kwargs['factory'] = self.make_session 

586 

587 return short_session(**kwargs) 

588 

589 def get_setting(self, session, name, **kwargs): 

590 """ 

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

592 

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

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

595 

596 Default implementation is just a convenience wrapper around 

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

598 

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

600 

601 :param session: App DB session. 

602 

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

604 

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

606 """ 

607 from .db import get_setting 

608 

609 return get_setting(session, name) 

610 

611 def save_setting( 

612 self, 

613 session, 

614 name, 

615 value, 

616 force_create=False, 

617 ): 

618 """ 

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

620 

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

622 

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

624 

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

626 

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

628 either a string or ``None``. 

629 

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

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

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

633 

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

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

636 exists. 

637 

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

639 """ 

640 model = self.model 

641 

642 # maybe fetch existing setting 

643 setting = None 

644 if not force_create: 

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

646 

647 # create setting if needed 

648 if not setting: 

649 setting = model.Setting(name=name) 

650 session.add(setting) 

651 

652 # set value 

653 setting.value = value 

654 

655 def delete_setting(self, session, name): 

656 """ 

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

658 

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

660 

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

662 

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

664 """ 

665 model = self.model 

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

667 if setting: 

668 session.delete(setting) 

669 

670 def continuum_is_enabled(self): 

671 """ 

672 Returns boolean indicating if Wutta-Continuum is installed and 

673 enabled. 

674 

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

676 installation and setup. For instructions see 

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

678 """ 

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

680 if hasattr(provider, 'continuum_is_enabled'): 

681 return provider.continuum_is_enabled() 

682 

683 return False 

684 

685 ############################## 

686 # common value renderers 

687 ############################## 

688 

689 def render_boolean(self, value): 

690 """ 

691 Render a boolean value for display. 

692 

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

694 

695 :returns: Display string for the value. 

696 """ 

697 if value is None: 

698 return '' 

699 

700 return "Yes" if value else "No" 

701 

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

703 """ 

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

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

706 

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

708 :class:`python:float` value. 

709 

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

711 

712 :returns: Display string for the value. 

713 """ 

714 if value is None: 

715 return '' 

716 

717 if value < 0: 

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

719 return fmt.format(0 - value) 

720 

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

722 return fmt.format(value) 

723 

724 display_format_date = '%Y-%m-%d' 

725 """ 

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

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

728 """ 

729 

730 display_format_datetime = '%Y-%m-%d %H:%M%z' 

731 """ 

732 Format string to use when displaying 

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

734 :meth:`render_datetime()`. 

735 """ 

736 

737 def render_date(self, value): 

738 """ 

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

740 

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

742 

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

744 ``None``). 

745 

746 :returns: Display string. 

747 """ 

748 if value is None: 

749 return "" 

750 return value.strftime(self.display_format_date) 

751 

752 def render_datetime(self, value): 

753 """ 

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

755 

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

757 

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

759 ``None``). 

760 

761 :returns: Display string. 

762 """ 

763 if value is None: 

764 return "" 

765 return value.strftime(self.display_format_datetime) 

766 

767 def render_error(self, error): 

768 """ 

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

770 when showing it to the user. 

771 

772 By default, this is a convenience wrapper for 

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

774 """ 

775 return simple_error(error) 

776 

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

778 """ 

779 Return a human-friendly display string for the given 

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

781 

782 :param value: The value to be rendered. 

783 

784 :returns: Display string for the percentage value. 

785 """ 

786 if value is None: 

787 return "" 

788 fmt = f'{{:0.{decimals}f}} %' 

789 if value < 0: 

790 return f'({fmt.format(-value)})' 

791 return fmt.format(value) 

792 

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

794 """ 

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

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

797 

798 :param value: The quantity to be rendered. 

799 

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

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

802 it returns empty string. 

803 

804 :returns: Display string for the quantity. 

805 """ 

806 if value is None: 

807 return '' 

808 if int(value) == value: 

809 value = int(value) 

810 if empty_zero and value == 0: 

811 return '' 

812 return str(value) 

813 return str(value).rstrip('0') 

814 

815 def render_time_ago(self, value): 

816 """ 

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

818 something occurred. 

819 

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

821 the rendering. 

822 

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

824 :class:`python:datetime.timedelta`. 

825 

826 :returns: Text to display. 

827 """ 

828 return humanize.naturaltime(value) 

829 

830 ############################## 

831 # getters for other handlers 

832 ############################## 

833 

834 def get_auth_handler(self, **kwargs): 

835 """ 

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

837 

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

839 """ 

840 if 'auth' not in self.handlers: 

841 spec = self.config.get(f'{self.appname}.auth.handler', 

842 default=self.default_auth_handler_spec) 

843 factory = self.load_object(spec) 

844 self.handlers['auth'] = factory(self.config, **kwargs) 

845 return self.handlers['auth'] 

846 

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

848 """ 

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

850 

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

852 

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

854 configured. 

855 

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

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

858 ``KeyError`` is raised. 

859 """ 

860 spec = self.config.get(f'{self.appname}.batch.{key}.handler.spec', 

861 default=default) 

862 if not spec: 

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

864 if not spec: 

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

866 factory = self.load_object(spec) 

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

868 

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

870 """ 

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

872 given batch type. 

873 

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

875 

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

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

878 

879 :returns: List of batch handler spec strings. 

880 

881 This will gather available spec strings from the following: 

882 

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

884 

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

886 

887 .. code-block:: ini 

888 

889 [wutta.batch] 

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

891 

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

893 ``pyproject.toml``: 

894 

895 .. code-block:: toml 

896 

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

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

899 

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

901 the latter registered handlers being sorted alphabetically. 

902 """ 

903 handlers = [] 

904 

905 # defaults from caller 

906 if isinstance(default, str): 

907 handlers.append(default) 

908 elif default: 

909 handlers.extend(default) 

910 

911 # configured default, if applicable 

912 default = self.config.get(f'{self.config.appname}.batch.{key}.handler.default_spec') 

913 if default and default not in handlers: 

914 handlers.append(default) 

915 

916 # registered via entry points 

917 registered = [] 

918 for Handler in load_entry_points(f'{self.appname}.batch.{key}').values(): 

919 spec = Handler.get_spec() 

920 if spec not in handlers: 

921 registered.append(spec) 

922 if registered: 

923 registered.sort() 

924 handlers.extend(registered) 

925 

926 return handlers 

927 

928 def get_db_handler(self, **kwargs): 

929 """ 

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

931 

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

933 """ 

934 if 'db' not in self.handlers: 

935 spec = self.config.get(f'{self.appname}.db.handler', 

936 default=self.default_db_handler_spec) 

937 factory = self.load_object(spec) 

938 self.handlers['db'] = factory(self.config, **kwargs) 

939 return self.handlers['db'] 

940 

941 def get_email_handler(self, **kwargs): 

942 """ 

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

944 

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

946 

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

948 """ 

949 if 'email' not in self.handlers: 

950 spec = self.config.get(f'{self.appname}.email.handler', 

951 default=self.default_email_handler_spec) 

952 factory = self.load_object(spec) 

953 self.handlers['email'] = factory(self.config, **kwargs) 

954 return self.handlers['email'] 

955 

956 def get_install_handler(self, **kwargs): 

957 """ 

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

959 

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

961 """ 

962 if 'install' not in self.handlers: 

963 spec = self.config.get(f'{self.appname}.install.handler', 

964 default=self.default_install_handler_spec) 

965 factory = self.load_object(spec) 

966 self.handlers['install'] = factory(self.config, **kwargs) 

967 return self.handlers['install'] 

968 

969 def get_people_handler(self, **kwargs): 

970 """ 

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

972 

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

974 """ 

975 if 'people' not in self.handlers: 

976 spec = self.config.get(f'{self.appname}.people.handler', 

977 default=self.default_people_handler_spec) 

978 factory = self.load_object(spec) 

979 self.handlers['people'] = factory(self.config, **kwargs) 

980 return self.handlers['people'] 

981 

982 def get_report_handler(self, **kwargs): 

983 """ 

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

985 

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

987 """ 

988 if 'reports' not in self.handlers: 

989 spec = self.config.get(f'{self.appname}.reports.handler_spec', 

990 default=self.default_report_handler_spec) 

991 factory = self.load_object(spec) 

992 self.handlers['reports'] = factory(self.config, **kwargs) 

993 return self.handlers['reports'] 

994 

995 ############################## 

996 # convenience delegators 

997 ############################## 

998 

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

1000 """ 

1001 Convenience method to locate a 

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

1003 object. 

1004 

1005 This delegates to the "people" handler method, 

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

1007 """ 

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

1009 

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

1011 """ 

1012 Send an email message. 

1013 

1014 This is a convenience wrapper around 

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

1016 """ 

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

1018 

1019 

1020class AppProvider: 

1021 """ 

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

1023 

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

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

1026 

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

1028 

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

1030 

1031 .. attribute:: config 

1032 

1033 Reference to the config object. 

1034 

1035 .. attribute:: app 

1036 

1037 Reference to the parent app handler. 

1038 

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

1040 various features with the app: 

1041 

1042 .. attribute:: email_modules 

1043 

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

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

1046 

1047 email_modules = ['poser.emails'] 

1048 

1049 .. attribute:: email_templates 

1050 

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

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

1053 

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

1055 

1056 email_templates = 'poser:templates/email' 

1057 

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

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

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

1061 """ 

1062 

1063 def __init__(self, config): 

1064 

1065 if isinstance(config, AppHandler): 

1066 warnings.warn("passing app handler to app provider is deprecated; " 

1067 "must pass config object instead", 

1068 DeprecationWarning, stacklevel=2) 

1069 config = config.config 

1070 

1071 self.config = config 

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

1073 

1074 @property 

1075 def appname(self): 

1076 """ 

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

1078 

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

1080 """ 

1081 return self.app.appname 

1082 

1083 

1084class GenericHandler: 

1085 """ 

1086 Generic base class for handlers. 

1087 

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

1089 may subclass this when defining the handler base class. 

1090 

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

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

1093 """ 

1094 

1095 def __init__(self, config): 

1096 self.config = config 

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

1098 

1099 @property 

1100 def appname(self): 

1101 """ 

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

1103 

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

1105 """ 

1106 return self.app.appname 

1107 

1108 @classmethod 

1109 def get_spec(cls): 

1110 """ 

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

1112 """ 

1113 return f'{cls.__module__}:{cls.__name__}'