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

186 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-08-27 21:08 -0500

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 importlib 

28import os 

29import sys 

30import warnings 

31 

32from wuttjamaican.util import (load_entry_points, load_object, 

33 make_title, make_uuid, parse_bool, 

34 progress_loop) 

35 

36 

37class AppHandler: 

38 """ 

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

40 handler`. 

41 

42 aka. "the handler to handle all handlers" 

43 

44 aka. "one handler to bind them all" 

45 

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

47 

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

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

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

51 

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

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

54 

55 .. attribute:: model 

56 

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

58 

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

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

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

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

63 happen automatically. 

64 

65 .. attribute:: enum 

66 

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

68 

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

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

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

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

73 happen automatically. 

74 

75 .. attribute:: providers 

76 

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

78 :meth:`get_all_providers()`. 

79 """ 

80 default_app_title = "WuttJamaican" 

81 default_model_spec = 'wuttjamaican.db.model' 

82 default_enum_spec = 'wuttjamaican.enum' 

83 default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler' 

84 default_email_handler_spec = 'wuttjamaican.email:EmailHandler' 

85 default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' 

86 

87 def __init__(self, config): 

88 self.config = config 

89 self.handlers = {} 

90 

91 @property 

92 def appname(self): 

93 """ 

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

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

96 

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

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

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

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

101 basis. 

102 """ 

103 return self.config.appname 

104 

105 def __getattr__(self, name): 

106 """ 

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

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

109 

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

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

112 attribute wins, and that value is returned. 

113 

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

115 providers. 

116 """ 

117 

118 if name == 'model': 

119 return self.get_model() 

120 

121 if name == 'enum': 

122 return self.get_enum() 

123 

124 if name == 'providers': 

125 self.providers = self.get_all_providers() 

126 return self.providers 

127 

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

129 if hasattr(provider, name): 

130 return getattr(provider, name) 

131 

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

133 

134 def get_all_providers(self): 

135 """ 

136 Load and return all registered providers. 

137 

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

139 use :attr:`providers`. 

140 

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

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

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

144 ``pyproject.toml``): 

145 

146 .. code-block:: toml 

147 

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

149 wuttaweb = "wuttaweb.app:WebAppProvider" 

150 

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

152 :class:`AppProvider` instances. 

153 """ 

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

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

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

157 for key in list(providers): 

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

159 return providers 

160 

161 def get_title(self, default=None): 

162 """ 

163 Returns the configured title for the app. 

164 

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

166 configured. 

167 

168 :returns: Title for the app. 

169 """ 

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

171 default=default or self.default_app_title) 

172 

173 def get_node_title(self, default=None): 

174 """ 

175 Returns the configured title for the local app node. 

176 

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

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

179 

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

181 configured. 

182 

183 :returns: Title for the local app node. 

184 """ 

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

186 if title: 

187 return title 

188 return self.get_title(default=default) 

189 

190 def get_node_type(self, default=None): 

191 """ 

192 Returns the "type" of current app node. 

193 

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

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

196 case it is needed by a particular app ecosystem. 

197 

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

199 

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

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

202 so: 

203 

204 .. code-block:: ini 

205 

206 [wutta] 

207 node_type = warehouse 

208 """ 

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

210 usedb=False) 

211 

212 def get_distribution(self, obj=None): 

213 """ 

214 Returns the appropriate Python distribution name. 

215 

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

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

218 object's type/class. 

219 

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

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

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

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

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

225 ``self`` instead of ``obj``. 

226 

227 In other words by default this returns the distribution to 

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

229 

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

231 

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

233 the appropriate distribution. 

234 

235 :returns: string, or ``None`` 

236 

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

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

239 PyPI for instance. 

240 

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

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

243 it in config: 

244 

245 .. code-block:: ini 

246 

247 [wutta] 

248 app_dist = My-Poser-Dist 

249 """ 

250 if obj is None: 

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

252 if dist: 

253 return dist 

254 

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

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

257 modpath = None 

258 if not modpath: 

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

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

261 

262 try: 

263 from importlib.metadata import packages_distributions 

264 except ImportError: # python < 3.10 

265 from importlib_metadata import packages_distributions 

266 

267 pkgmap = packages_distributions() 

268 if pkgname in pkgmap: 

269 dist = pkgmap[pkgname][0] 

270 return dist 

271 

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

273 if obj is not None: 

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

275 

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

277 """ 

278 Returns the version of a given Python distribution. 

279 

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

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

282 

283 So by default this will return the version of whichever 

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

285 

286 :returns: Version as string. 

287 """ 

288 from importlib.metadata import version 

289 

290 if not dist: 

291 dist = self.get_distribution(obj=obj) 

292 if dist: 

293 return version(dist) 

294 

295 def get_model(self): 

296 """ 

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

298 

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

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

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

302 

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

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

305 provided another default. 

306 

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

308 extension):: 

309 

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

311 """ 

312 if 'model' not in self.__dict__: 

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

314 usedb=False, 

315 default=self.default_model_spec) 

316 self.model = importlib.import_module(spec) 

317 return self.model 

318 

319 def get_enum(self): 

320 """ 

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

322 

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

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

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

326 

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

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

329 another default. 

330 

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

332 extension):: 

333 

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

335 """ 

336 if 'enum' not in self.__dict__: 

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

338 usedb=False, 

339 default=self.default_enum_spec) 

340 self.enum = importlib.import_module(spec) 

341 return self.enum 

342 

343 def load_object(self, spec): 

344 """ 

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

346 given spec string. 

347 

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

349 

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

351 

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

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

354 given name, then an error will raise. 

355 """ 

356 return load_object(spec) 

357 

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

359 """ 

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

361 

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

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

364 

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

366 the configured path and ignore the default path. 

367 

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

369 returned path exists, creating it if necessary. 

370 

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

372 paths for the final value. 

373 

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

375 environment root:: 

376 

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

378 

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

380 """ 

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

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

383 

384 # maybe specify default path 

385 if not configured_only: 

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

387 kwargs.setdefault('default', path) 

388 

389 # get configured path 

390 kwargs.setdefault('usedb', False) 

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

392 

393 # add any subpath info 

394 if path and args: 

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

396 

397 # create path if requested/needed 

398 if create: 

399 if not path: 

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

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

402 os.makedirs(path) 

403 

404 return path 

405 

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

407 """ 

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

409 

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

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

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

413 

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

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

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

417 when this method is called. 

418 

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

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

421 ``['data', 'log', 'work']``. 

422 """ 

423 appdir = path 

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

425 os.makedirs(appdir) 

426 

427 if not subfolders: 

428 subfolders = ['data', 'log', 'work'] 

429 

430 for name in subfolders: 

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

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

433 os.mkdir(path) 

434 

435 def make_session(self, **kwargs): 

436 """ 

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

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

439 instance. 

440 

441 :returns: SQLAlchemy session for the app DB. 

442 """ 

443 from .db import Session 

444 

445 return Session(**kwargs) 

446 

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

448 """ 

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

450 

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

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

453 

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

455 

456 By default this just invokes 

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

458 """ 

459 return make_title(text) 

460 

461 def make_uuid(self): 

462 """ 

463 Generate a new UUID value. 

464 

465 By default this simply calls 

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

467 

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

469 """ 

470 return make_uuid() 

471 

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

473 """ 

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

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

476 way. 

477 

478 This is a wrapper around 

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

480 param details. 

481 """ 

482 return progress_loop(*args, **kwargs) 

483 

484 def get_session(self, obj): 

485 """ 

486 Returns the SQLAlchemy session with which the given object is 

487 associated. Simple convenience wrapper around 

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

489 """ 

490 from sqlalchemy import orm 

491 

492 return orm.object_session(obj) 

493 

494 def short_session(self, **kwargs): 

495 """ 

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

497 

498 This is a convenience wrapper around 

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

500 

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

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

503 :meth:`make_session`. 

504 """ 

505 from .db import short_session 

506 

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

508 kwargs['factory'] = self.make_session 

509 

510 return short_session(**kwargs) 

511 

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

513 """ 

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

515 

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

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

518 

519 Default implementation is just a convenience wrapper around 

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

521 

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

523 

524 :param session: App DB session. 

525 

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

527 

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

529 """ 

530 from .db import get_setting 

531 

532 return get_setting(session, name) 

533 

534 def save_setting( 

535 self, 

536 session, 

537 name, 

538 value, 

539 force_create=False, 

540 ): 

541 """ 

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

543 

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

545 

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

547 

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

549 

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

551 either a string or ``None``. 

552 

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

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

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

556 

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

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

559 exists. 

560 

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

562 """ 

563 model = self.model 

564 

565 # maybe fetch existing setting 

566 setting = None 

567 if not force_create: 

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

569 

570 # create setting if needed 

571 if not setting: 

572 setting = model.Setting(name=name) 

573 session.add(setting) 

574 

575 # set value 

576 setting.value = value 

577 

578 def delete_setting(self, session, name): 

579 """ 

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

581 

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

583 

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

585 

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

587 """ 

588 model = self.model 

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

590 if setting: 

591 session.delete(setting) 

592 

593 def continuum_is_enabled(self): 

594 """ 

595 Returns boolean indicating if Wutta-Continuum is installed and 

596 enabled. 

597 

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

599 installation and setup. For instructions see 

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

601 """ 

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

603 if hasattr(provider, 'continuum_is_enabled'): 

604 return provider.continuum_is_enabled() 

605 

606 return False 

607 

608 ############################## 

609 # getters for other handlers 

610 ############################## 

611 

612 def get_auth_handler(self, **kwargs): 

613 """ 

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

615 

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

617 """ 

618 if 'auth' not in self.handlers: 

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

620 default=self.default_auth_handler_spec) 

621 factory = self.load_object(spec) 

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

623 return self.handlers['auth'] 

624 

625 def get_email_handler(self, **kwargs): 

626 """ 

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

628 

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

630 

631 :rtype: :class:`~wuttjamaican.email.handler.EmailHandler` 

632 """ 

633 if 'email' not in self.handlers: 

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

635 default=self.default_email_handler_spec) 

636 factory = self.load_object(spec) 

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

638 return self.handlers['email'] 

639 

640 def get_people_handler(self, **kwargs): 

641 """ 

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

643 

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

645 """ 

646 if 'people' not in self.handlers: 

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

648 default=self.default_people_handler_spec) 

649 factory = self.load_object(spec) 

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

651 return self.handlers['people'] 

652 

653 ############################## 

654 # convenience delegators 

655 ############################## 

656 

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

658 """ 

659 Convenience method to locate a 

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

661 object. 

662 

663 This delegates to the "people" handler method, 

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

665 """ 

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

667 

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

669 """ 

670 Send an email message. 

671 

672 This is a convenience wrapper around 

673 :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`. 

674 """ 

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

676 

677 

678class AppProvider: 

679 """ 

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

681 

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

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

684 

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

686 

687 Instances have the following attributes: 

688 

689 .. attribute:: config 

690 

691 Reference to the config object. 

692 

693 .. attribute:: app 

694 

695 Reference to the parent app handler. 

696 """ 

697 

698 def __init__(self, config): 

699 

700 if isinstance(config, AppHandler): 

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

702 "must pass config object instead", 

703 DeprecationWarning, stacklevel=2) 

704 config = config.config 

705 

706 self.config = config 

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

708 

709 @property 

710 def appname(self): 

711 """ 

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

713 

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

715 """ 

716 return self.app.appname 

717 

718 

719class GenericHandler: 

720 """ 

721 Generic base class for handlers. 

722 

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

724 may subclass this when defining the handler base class. 

725 

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

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

728 """ 

729 

730 def __init__(self, config, **kwargs): 

731 self.config = config 

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

733 

734 @property 

735 def appname(self): 

736 """ 

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

738 

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

740 """ 

741 return self.app.appname