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

267 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-28 15:05 -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 configuration 

25""" 

26# pylint: disable=too-many-lines 

27 

28import configparser 

29import logging 

30import logging.config 

31import os 

32import sys 

33import tempfile 

34import warnings 

35 

36import config as configuration 

37 

38from wuttjamaican.util import ( 

39 load_entry_points, 

40 load_object, 

41 parse_bool, 

42 parse_list, 

43 UNSPECIFIED, 

44) 

45from wuttjamaican.exc import ConfigurationError 

46 

47 

48log = logging.getLogger(__name__) 

49 

50 

51class WuttaConfig: # pylint: disable=too-many-instance-attributes 

52 """ 

53 Configuration class for Wutta Framework 

54 

55 A single instance of this class is typically created on app 

56 startup, by calling :func:`make_config()`. 

57 

58 The global config object is mainly responsible for providing 

59 config values to the app, via :meth:`get()` and similar methods. 

60 

61 The config object may have more than one place to look when 

62 finding values. This can vary somewhat but often the priority for 

63 lookup is like: 

64 

65 * settings table in the DB 

66 * one or more INI files 

67 * "defaults" provided by app logic 

68 

69 :param files: Optional list of file paths from which to read 

70 config values. 

71 

72 :param defaults: Optional dict of initial values to use as 

73 defaults. This gets converted to :attr:`defaults` during 

74 construction. 

75 

76 :param appname: Value to assign for :attr:`appname`. 

77 

78 :param usedb: Flag indicating whether config values should ever be 

79 looked up from the DB. Note that you can override this when 

80 calling :meth:`get()`. 

81 

82 :param preferdb: Flag indicating whether values from DB should be 

83 preferred over the values from INI files or app defaults. Note 

84 that you can override this when calling :meth:`get()`. 

85 

86 :param configure_logging: Flag indicating whether logging should 

87 be configured during object construction. If not specified, 

88 the config values will determine behavior. 

89 

90 Attributes available on the config instance: 

91 

92 .. attribute:: appname 

93 

94 Code-friendly name ("key") for the app. This is used as the 

95 basis for various config settings and will therefore determine 

96 what is returned from :meth:`get_app()` etc. 

97 

98 For instance the default ``appname`` value is ``'wutta'`` which 

99 means a sample config file might look like: 

100 

101 .. code-block:: ini 

102 

103 [wutta] 

104 app.handler = wuttjamaican.app:AppHandler 

105 

106 [wutta.db] 

107 default.url = sqlite:// 

108 

109 But if the ``appname`` value is e.g. ``'rattail'`` then the 

110 sample config should instead look like: 

111 

112 .. code-block:: ini 

113 

114 [rattail] 

115 app.handler = wuttjamaican.app:AppHandler 

116 

117 [rattail.db] 

118 default.url = sqlite:// 

119 

120 .. attribute:: configuration 

121 

122 Reference to the 

123 :class:`python-configuration:config.ConfigurationSet` instance 

124 which houses the full set of config values which are kept in 

125 memory. This does *not* contain settings from DB, but *does* 

126 contain :attr:`defaults` as well as values read from INI files. 

127 

128 .. attribute:: defaults 

129 

130 Reference to the 

131 :class:`python-configuration:config.Configuration` instance 

132 containing config *default* values. This is exposed in case 

133 it's useful, but in practice you should not update it directly; 

134 instead use :meth:`setdefault()`. 

135 

136 .. attribute:: default_app_handler_spec 

137 

138 Spec string for the default app handler, if config does not 

139 specify to use another. 

140 

141 The true default for this is ``'wuttjamaican.app:AppHandler'`` 

142 (aka. :class:`~wuttjamaican.app.AppHandler`). 

143 

144 .. attribute:: default_engine_maker_spec 

145 

146 Spec string for the default engine maker function, if config 

147 does not specify to use another. 

148 

149 The true default for this is 

150 ``'wuttjamaican.db.conf:make_engine_from_config'`` (aka. 

151 :func:`~wuttjamaican.db.conf.make_engine_from_config()`). 

152 

153 .. attribute:: files_read 

154 

155 List of all INI config files which were read on app startup. 

156 These are listed in the same order as they were read. This 

157 sequence also reflects priority for value lookups, i.e. the 

158 first file with the value wins. 

159 

160 .. attribute:: usedb 

161 

162 Whether the :term:`settings table` should be searched for 

163 config settings. This is ``False`` by default but may be 

164 enabled via config file: 

165 

166 .. code-block:: ini 

167 

168 [wutta.config] 

169 usedb = true 

170 

171 See also :ref:`where-config-settings-come-from`. 

172 

173 .. attribute:: preferdb 

174 

175 Whether the :term:`settings table` should be preferred over 

176 :term:`config files<config file>` when looking for config 

177 settings. This is ``False`` by default, and in any case is 

178 ignored unless :attr:`usedb` is ``True``. 

179 

180 Most apps will want to enable this flag so that when the 

181 settings table is updated, it will immediately affect app 

182 behavior regardless of what values are in the config files. 

183 

184 .. code-block:: ini 

185 

186 [wutta.config] 

187 usedb = true 

188 preferdb = true 

189 

190 See also :ref:`where-config-settings-come-from`. 

191 """ 

192 

193 _app = None 

194 default_app_handler_spec = "wuttjamaican.app:AppHandler" 

195 default_engine_maker_spec = "wuttjamaican.db.conf:make_engine_from_config" 

196 

197 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments 

198 self, 

199 files=None, 

200 defaults=None, 

201 appname="wutta", 

202 usedb=None, 

203 preferdb=None, 

204 configure_logging=None, 

205 ): 

206 self.appname = appname 

207 configs = [] 

208 

209 # read all files requested 

210 self.files_read = [] 

211 for path in files or []: 

212 self._load_ini_configs(path, configs, require=True) 

213 

214 # add config for use w/ setdefault() 

215 self.defaults = configuration.Configuration(defaults or {}) 

216 configs.append(self.defaults) 

217 

218 # master config set 

219 self.configuration = configuration.ConfigurationSet(*configs) 

220 

221 # establish logging 

222 if configure_logging is None: 

223 configure_logging = self.get_bool( 

224 f"{self.appname}.config.configure_logging", default=False, usedb=False 

225 ) 

226 if configure_logging: 

227 self._configure_logging() 

228 

229 # usedb flag 

230 self.usedb = usedb 

231 if self.usedb is None: 

232 self.usedb = self.get_bool( 

233 f"{self.appname}.config.usedb", default=False, usedb=False 

234 ) 

235 

236 # preferdb flag 

237 self.preferdb = preferdb 

238 if self.usedb and self.preferdb is None: 

239 self.preferdb = self.get_bool( 

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

241 ) 

242 

243 # configure main app DB if applicable, or disable usedb flag 

244 try: 

245 from wuttjamaican.db import ( # pylint: disable=import-outside-toplevel 

246 Session, 

247 get_engines, 

248 ) 

249 except ImportError: 

250 if self.usedb: 

251 log.warning( 

252 "config created with `usedb = True`, but can't import " 

253 "DB module(s), so setting `usedb = False` instead", 

254 exc_info=True, 

255 ) 

256 self.usedb = False 

257 self.preferdb = False 

258 else: 

259 self.appdb_engines = get_engines(self, f"{self.appname}.db") 

260 self.appdb_engine = self.appdb_engines.get("default") 

261 Session.configure(bind=self.appdb_engine) 

262 

263 log.debug("config files read: %s", self.files_read) 

264 

265 def _load_ini_configs(self, path, configs, require=True): 

266 path = os.path.abspath(path) 

267 

268 # no need to read a file twice; its first appearance sets priority 

269 if path in self.files_read: 

270 return 

271 

272 # try to load config with standard parser, and default vars 

273 here = os.path.dirname(path) 

274 config = configparser.ConfigParser(defaults={"here": here, "__file__": path}) 

275 if not config.read(path): 

276 if require: 

277 raise FileNotFoundError(f"could not read required config file: {path}") 

278 return 

279 

280 # write config to temp file 

281 temp_path = self._write_temp_config_file(config) 

282 

283 # and finally, load that into our main config 

284 config = configuration.config_from_ini(temp_path, read_from_file=True) 

285 configs.append(config) 

286 self.files_read.append(path) 

287 os.remove(temp_path) 

288 

289 # bring in any "required" files 

290 requires = config.get(f"{self.appname}.config.require") 

291 if requires: 

292 for p in self.parse_list(requires): 

293 self._load_ini_configs(p, configs, require=True) 

294 

295 # bring in any "included" files 

296 includes = config.get(f"{self.appname}.config.include") 

297 if includes: 

298 for p in self.parse_list(includes): 

299 self._load_ini_configs(p, configs, require=False) 

300 

301 def _write_temp_config_file(self, config): 

302 # load all values into (yet another) temp config 

303 temp_config = configparser.RawConfigParser() 

304 for section in config.sections(): 

305 temp_config.add_section(section) 

306 # nb. must interpolate most values but *not* for logging formatters 

307 raw = section.startswith("formatter_") 

308 for option in config.options(section): 

309 temp_config.set(section, option, config.get(section, option, raw=raw)) 

310 

311 # re-write as temp file with "final" values 

312 fd, temp_path = tempfile.mkstemp(suffix=".ini") 

313 os.close(fd) 

314 with open(temp_path, "wt", encoding="utf_8") as f: 

315 temp_config.write(f) 

316 

317 return temp_path 

318 

319 def get_prioritized_files(self): 

320 """ 

321 Returns list of config files in order of priority. 

322 

323 By default, :attr:`files_read` should already be in the 

324 correct order, but this is to make things more explicit. 

325 """ 

326 return self.files_read 

327 

328 def setdefault(self, key, value): 

329 """ 

330 Establish a default config value for the given key. 

331 

332 Note that there is only *one* default value per key. If 

333 multiple calls are made with the same key, the first will set 

334 the default and subsequent calls have no effect. 

335 

336 :returns: The current config value, *outside of the DB*. For 

337 various reasons this method may not be able to lookup 

338 settings from the DB, e.g. during app init. So it can only 

339 determine the value per INI files + config defaults. 

340 """ 

341 # set default value, if not already set 

342 self.defaults.setdefault(key, value) 

343 

344 # get current value, sans db 

345 return self.get(key, usedb=False) 

346 

347 def get( # pylint: disable=too-many-arguments,too-many-positional-arguments 

348 self, 

349 key, 

350 default=UNSPECIFIED, 

351 require=False, 

352 ignore_ambiguous=False, 

353 message=None, 

354 usedb=None, 

355 preferdb=None, 

356 session=None, 

357 **kwargs, 

358 ): 

359 """ 

360 Retrieve a string value from config. 

361 

362 .. warning:: 

363 

364 While the point of this method is to return a *string* 

365 value, it is possible for a key to be present in config 

366 which corresponds to a "subset" of the config, and not a 

367 simple value. For instance with this config file: 

368 

369 .. code-block:: ini 

370 

371 [foo] 

372 bar = 1 

373 bar.baz = 2 

374 

375 If you invoke ``config.get('foo.bar')`` the return value 

376 is somewhat ambiguous. At first glance it should return 

377 ``'1'`` - but just as valid would be to return the dict:: 

378 

379 {'baz': '2'} 

380 

381 And similarly, if you invoke ``config.get('foo')`` then 

382 the return value "should be" the dict:: 

383 

384 {'bar': '1', 

385 'bar.baz': '2'} 

386 

387 Despite all that ambiguity, again the whole point of this 

388 method is to return a *string* value, only. Therefore in 

389 any case where the return value "should be" a dict, per 

390 logic described above, this method will *ignore* that and 

391 simply return ``None`` (or rather the ``default`` value). 

392 

393 It is important also to understand that in fact, there is 

394 no "real" ambiguity per se, but rather a dict (subset) 

395 would always get priority over a simple string value. So 

396 in the first example above, ``config.get('foo.bar')`` will 

397 always return the ``default`` value. The string value 

398 ``'1'`` will never be returned since the dict/subset 

399 overshadows it, and this method will only return the 

400 default value in lieu of any dict. 

401 

402 :param key: String key for which value should be returned. 

403 

404 :param default: Default value to be returned, if config does 

405 not contain the key. If no default is specified, ``None`` 

406 will be assumed. 

407 

408 :param require: If set, an error will be raised if config does 

409 not contain the key. If not set, default value is returned 

410 (which may be ``None``). 

411 

412 Note that it is an error to specify a default value if you 

413 also specify ``require=True``. 

414 

415 :param ignore_ambiguous: By default this method will log a 

416 warning if an ambiguous value is detected (as described 

417 above). Pass a true value for this flag to avoid the 

418 warnings. Should use with caution, as the warnings are 

419 there for a reason. 

420 

421 :param message: Optional first part of message to be used, 

422 when raising a "value not found" error. If not specified, 

423 a default error message will be generated. 

424 

425 :param usedb: Flag indicating whether config values should be 

426 looked up from the DB. The default for this param is 

427 ``None``, in which case the :attr:`usedb` flag determines 

428 the behavior. 

429 

430 :param preferdb: Flag indicating whether config values from DB 

431 should be preferred over values from INI files and/or app 

432 defaults. The default for this param is ``None``, in which 

433 case the :attr:`preferdb` flag determines the behavior. 

434 

435 :param session: Optional SQLAlchemy session to use for DB lookups. 

436 NOTE: This param is not yet implemented; currently ignored. 

437 

438 :param \\**kwargs: Any remaining kwargs are passed as-is to 

439 the :meth:`get_from_db()` call, if applicable. 

440 

441 :returns: Value as string. 

442 

443 """ 

444 if require and default is not UNSPECIFIED: 

445 raise ValueError("must not specify default value when require=True") 

446 

447 # should we use/prefer db? 

448 if usedb is None: 

449 usedb = self.usedb 

450 if usedb and preferdb is None: 

451 preferdb = self.preferdb 

452 

453 # read from db first if so requested 

454 if usedb and preferdb: 

455 value = self.get_from_db(key, session=session, **kwargs) 

456 if value is not None: 

457 return value 

458 

459 # read from defaults + INI files 

460 value = self.configuration.get(key) 

461 if value is not None: 

462 # nb. if the "value" corresponding to the given key is in 

463 # fact a subset/dict of more config values, then we must 

464 # "ignore" that. so only return the value if it is *not* 

465 # such a config subset. 

466 if not isinstance(value, configuration.Configuration): 

467 return value 

468 

469 if not ignore_ambiguous: 

470 log.warning("ambiguous config key '%s' returns: %s", key, value) 

471 

472 # read from db last if so requested 

473 if usedb and not preferdb: 

474 value = self.get_from_db(key, session=session, **kwargs) 

475 if value is not None: 

476 return value 

477 

478 # raise error if required value not found 

479 if require: 

480 message = message or "missing config" 

481 raise ConfigurationError(f"{message}; set value for: {key}") 

482 

483 # give the default value if specified 

484 if default is not UNSPECIFIED: 

485 return default 

486 

487 return None 

488 

489 def get_from_db(self, key, session=None, **kwargs): 

490 """ 

491 Retrieve a config value from database settings table. 

492 

493 This is a convenience wrapper around 

494 :meth:`~wuttjamaican.app.AppHandler.get_setting()`. 

495 """ 

496 app = self.get_app() 

497 with app.short_session(session=session) as s: 

498 return app.get_setting(s, key, **kwargs) 

499 

500 def require(self, *args, **kwargs): 

501 """ 

502 Retrieve a value from config, or raise error if no value can 

503 be found. This is just a shortcut, so these work the same:: 

504 

505 config.get('foo', require=True) 

506 

507 config.require('foo') 

508 """ 

509 kwargs["require"] = True 

510 return self.get(*args, **kwargs) 

511 

512 def get_bool(self, *args, **kwargs): 

513 """ 

514 Retrieve a boolean value from config. 

515 

516 Accepts same params as :meth:`get()` but if a value is found, 

517 it will be coerced to boolean via :meth:`parse_bool()`. 

518 """ 

519 value = self.get(*args, **kwargs) 

520 return self.parse_bool(value) 

521 

522 def get_int(self, *args, **kwargs): 

523 """ 

524 Retrieve an integer value from config. 

525 

526 Accepts same params as :meth:`get()` but if a value is found, 

527 it will be coerced to integer via the :class:`python:int()` 

528 constructor. 

529 """ 

530 value = self.get(*args, **kwargs) 

531 if value is not None: 

532 return int(value) 

533 return None 

534 

535 def get_list(self, *args, **kwargs): 

536 """ 

537 Retrieve a list value from config. 

538 

539 Accepts same params as :meth:`get()` but if a value is found, 

540 it will be coerced to list via :meth:`parse_list()`. 

541 

542 :returns: If a value is found, a list is returned. If no 

543 value, returns ``None``. 

544 """ 

545 value = self.get(*args, **kwargs) 

546 if value is not None: 

547 return self.parse_list(value) 

548 return None 

549 

550 def get_dict(self, prefix): 

551 """ 

552 Retrieve a particular group of values, as a dictionary. 

553 

554 Please note, this will only return values from INI files + 

555 defaults. It will *not* return values from DB settings. In 

556 other words it assumes ``usedb=False``. 

557 

558 For example given this config file: 

559 

560 .. code-block:: ini 

561 

562 [wutta.db] 

563 keys = default, host 

564 default.url = sqlite:///tmp/default.sqlite 

565 host.url = sqlite:///tmp/host.sqlite 

566 host.pool_pre_ping = true 

567 

568 One can get the "dict" for SQLAlchemy engine config via:: 

569 

570 config.get_dict('wutta.db') 

571 

572 And the dict would look like:: 

573 

574 {'keys': 'default, host', 

575 'default.url': 'sqlite:///tmp/default.sqlite', 

576 'host.url': 'sqlite:///tmp/host.sqlite', 

577 'host.pool_pre_ping': 'true'} 

578 

579 :param prefix: String prefix corresponding to a subsection of 

580 the config. 

581 

582 :returns: Dictionary containing the config subsection. 

583 """ 

584 try: 

585 values = self.configuration[prefix] 

586 except KeyError: 

587 return {} 

588 

589 return values.as_dict() 

590 

591 def parse_bool(self, value): 

592 """ 

593 Convenience wrapper for 

594 :func:`wuttjamaican.util.parse_bool()`. 

595 """ 

596 return parse_bool(value) 

597 

598 def parse_list(self, value): 

599 """ 

600 Convenience wrapper for 

601 :func:`wuttjamaican.util.parse_list()`. 

602 """ 

603 return parse_list(value) 

604 

605 def _configure_logging(self): 

606 """ 

607 This will save the current config parser defaults to a 

608 temporary file, and use this file to configure Python's 

609 standard logging module. 

610 """ 

611 # write current values to file suitable for logging auto-config 

612 path = self._write_logging_config_file() 

613 try: 

614 logging.config.fileConfig(path, disable_existing_loggers=False) 

615 except configparser.NoSectionError as error: 

616 log.warning("tried to configure logging, but got NoSectionError: %s", error) 

617 else: 

618 log.debug("configured logging") 

619 log.debug("sys.argv: %s", sys.argv) 

620 finally: 

621 os.remove(path) 

622 

623 def _write_logging_config_file(self): 

624 # load all current values into configparser 

625 parser = configparser.RawConfigParser() 

626 for section, values in self.configuration.items(): 

627 parser.add_section(section) 

628 for option, value in values.items(): 

629 parser.set(section, option, value) 

630 

631 # write INI file and return path 

632 fd, path = tempfile.mkstemp(suffix=".conf") 

633 os.close(fd) 

634 with open(path, "wt", encoding="utf_8") as f: 

635 parser.write(f) 

636 return path 

637 

638 def get_app(self): 

639 """ 

640 Returns the global :class:`~wuttjamaican.app.AppHandler` 

641 instance, creating it if necessary. 

642 

643 See also :doc:`/narr/handlers/app`. 

644 """ 

645 if not self._app: 

646 spec = self.get( 

647 f"{self.appname}.app.handler", 

648 usedb=False, 

649 default=self.default_app_handler_spec, 

650 ) 

651 factory = load_object(spec) 

652 self._app = factory(self) 

653 return self._app 

654 

655 def get_engine_maker(self): 

656 """ 

657 Returns a callable to be used for constructing SQLAlchemy 

658 engines fromc config. 

659 

660 Which callable is used depends on 

661 :attr:`default_engine_maker_spec` but by default will be 

662 :func:`wuttjamaican.db.conf.make_engine_from_config()`. 

663 """ 

664 return load_object(self.default_engine_maker_spec) 

665 

666 def production(self): 

667 """ 

668 Returns boolean indicating whether the app is running in 

669 production mode. 

670 

671 This value may be set e.g. in config file: 

672 

673 .. code-block:: ini 

674 

675 [wutta] 

676 production = true 

677 """ 

678 return self.get_bool(f"{self.appname}.production", default=False) 

679 

680 

681class WuttaConfigExtension: 

682 """ 

683 Base class for all :term:`config extensions <config extension>`. 

684 """ 

685 

686 key = None 

687 

688 def __repr__(self): 

689 return f"WuttaConfigExtension(key={self.key})" 

690 

691 def configure(self, config): 

692 """ 

693 Subclass should override this method, to extend the config 

694 object in any way necessary. 

695 """ 

696 

697 def startup(self, config): 

698 """ 

699 This method is called after the config object is fully created 

700 and all extensions have been applied, i.e. after 

701 :meth:`configure()` has been called for each extension. 

702 

703 At this point the config *settings* for the running app should 

704 be settled, and each extension is then allowed to act on those 

705 initial settings if needed. 

706 """ 

707 

708 

709def generic_default_files(appname): 

710 """ 

711 Returns a list of default file paths which might be used for 

712 making a config object. This function does not check if the paths 

713 actually exist. 

714 

715 :param appname: App name to be used as basis for default filenames. 

716 

717 :returns: List of default file paths. 

718 """ 

719 if sys.platform == "win32": 

720 # use pywin32 to fetch official defaults 

721 try: 

722 from win32com.shell import ( # pylint: disable=import-outside-toplevel 

723 shell, 

724 shellcon, 

725 ) 

726 except ImportError: 

727 return [] 

728 

729 return [ 

730 # e.g. C:\..?? TODO: what is the user-specific path on win32? 

731 os.path.join( 

732 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_APPDATA), 

733 appname, 

734 f"{appname}.conf", 

735 ), 

736 os.path.join( 

737 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_APPDATA), 

738 f"{appname}.conf", 

739 ), 

740 # e.g. C:\ProgramData\wutta\wutta.conf 

741 os.path.join( 

742 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_COMMON_APPDATA), 

743 appname, 

744 f"{appname}.conf", 

745 ), 

746 os.path.join( 

747 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_COMMON_APPDATA), 

748 f"{appname}.conf", 

749 ), 

750 ] 

751 

752 # default paths for *nix 

753 return [ 

754 f"{sys.prefix}/app/{appname}.conf", 

755 os.path.expanduser(f"~/.{appname}/{appname}.conf"), 

756 os.path.expanduser(f"~/.{appname}.conf"), 

757 f"/usr/local/etc/{appname}/{appname}.conf", 

758 f"/usr/local/etc/{appname}.conf", 

759 f"/etc/{appname}/{appname}.conf", 

760 f"/etc/{appname}.conf", 

761 ] 

762 

763 

764def get_config_paths( # pylint: disable=too-many-arguments,too-many-positional-arguments 

765 files=None, 

766 plus_files=None, 

767 appname="wutta", 

768 env_files_name=None, 

769 env_plus_files_name=None, 

770 env=None, 

771 default_files=None, 

772 winsvc=None, 

773): 

774 """ 

775 This function determines which files should ultimately be provided 

776 to the config constructor. It is normally called by 

777 :func:`make_config()`. 

778 

779 In short, the files to be used are determined by typical priority: 

780 

781 * function params - ``files`` and ``plus_files`` 

782 * environment variables - e.g. ``WUTTA_CONFIG_FILES`` 

783 * app defaults - e.g. :func:`generic_default_files()` 

784 

785 The "main" and so-called "plus" config files are dealt with 

786 separately, so that "defaults" can be used for the main files, and 

787 any "plus" files are then added to the result. 

788 

789 In the end it combines everything it finds into a single list. 

790 Note that it does not necessarily check to see if these files 

791 exist. 

792 

793 :param files: Explicit set of "main" config files. If not 

794 specified, environment variables and/or default lookup will be 

795 done to get the "main" file set. Specify an empty list to 

796 force an empty main file set. 

797 

798 :param plus_files: Explicit set of "plus" config files. Same 

799 rules apply here as for the ``files`` param. 

800 

801 :param appname: The "app name" to use as basis for other things - 

802 namely, constructing the default config file paths etc. For 

803 instance the default ``appname`` value is ``'wutta'`` which 

804 leads to default env vars like ``WUTTA_CONFIG_FILES``. 

805 

806 :param env_files_name: Name of the environment variable to read, 

807 if ``files`` is not specified. The default is 

808 ``WUTTA_CONFIG_FILES`` unless you override ``appname``. 

809 

810 :param env_plus_files_name: Name of the environment variable to 

811 read, if ``plus_files`` is not specified. The default is 

812 ``WUTTA_CONFIG_PLUS_FILES`` unless you override ``appname``. 

813 

814 :param env: Optional environment dict; if not specified 

815 ``os.environ`` is used. 

816 

817 :param default_files: Optional lookup for "default" file paths. 

818 

819 This is only used a) for the "main" config file lookup (but not 

820 "plus" files), and b) if neither ``files`` nor the environment 

821 variables yielded anything. 

822 

823 If not specified, :func:`generic_default_files()` will be used 

824 for the lookup. 

825 

826 You may specify a single file path as string, or a list of file 

827 paths, or a callable which returns either of those things. For 

828 example any of these could be used:: 

829 

830 mydefaults = '/tmp/something.conf' 

831 

832 mydefaults = [ 

833 '/tmp/something.conf', 

834 '/tmp/else.conf', 

835 ] 

836 

837 def mydefaults(appname): 

838 return [ 

839 f"/tmp/{appname}.conf", 

840 f"/tmp/{appname}.ini", 

841 ] 

842 

843 files = get_config_paths(default_files=mydefaults) 

844 

845 :param winsvc: Optional internal name of the Windows service for 

846 which the config object is being made. 

847 

848 This is only needed for true Windows services running via 

849 "Python for Windows Extensions" - which probably only includes 

850 the Rattail File Monitor service. 

851 

852 In this context there is no way to tell the app which config 

853 files to read on startup, so it can only look for "default" 

854 files. But by passing a ``winsvc`` name to this function, it 

855 will first load the default config file, then read a particular 

856 value to determine the "real" config file(s) it should use. 

857 

858 So for example on Windows you might have a config file at 

859 ``C:\\ProgramData\\rattail\\rattail.conf`` with contents: 

860 

861 .. code-block:: ini 

862 

863 [rattail.config] 

864 winsvc.RattailFileMonitor = C:\\ProgramData\\rattail\\filemon.conf 

865 

866 And then ``C:\\ProgramData\\rattail\\filemon.conf`` would have 

867 the actual config for the filemon service. 

868 

869 When the service starts it calls:: 

870 

871 make_config(winsvc='RattailFileMonitor') 

872 

873 which first reads the ``rattail.conf`` file (since that is the 

874 only sensible default), but then per config it knows to swap 

875 that out for ``filemon.conf`` at startup. This is because it 

876 finds a config value matching the requested service name. The 

877 end result is as if it called this instead:: 

878 

879 make_config(files=[r'C:\\ProgramData\\rattail\\filemon.conf']) 

880 

881 :returns: List of file paths. 

882 """ 

883 if env is None: 

884 env = os.environ 

885 

886 # first identify any "primary" config files 

887 if files is None: 

888 files = _get_primary_config_files(appname, env, env_files_name, default_files) 

889 elif isinstance(files, str): 

890 files = [files] 

891 else: 

892 files = list(files) 

893 

894 # then identify any "plus" (config tweak) files 

895 if plus_files is None: 

896 if not env_plus_files_name: 

897 env_plus_files_name = f"{appname.upper()}_CONFIG_PLUS_FILES" 

898 

899 plus_files = env.get(env_plus_files_name) 

900 if plus_files is not None: 

901 plus_files = plus_files.split(os.pathsep) 

902 

903 else: 

904 plus_files = [] 

905 

906 elif isinstance(plus_files, str): 

907 plus_files = [plus_files] 

908 else: 

909 plus_files = list(plus_files) 

910 

911 # combine all files 

912 files.extend(plus_files) 

913 

914 # when running as a proper windows service, must first read 

915 # "default" file(s) and then consult config to see which file 

916 # should "really" be used. because there isn't a way to specify 

917 # which config file as part of the actual service definition in 

918 # windows, so the service name is used for magic lookup here. 

919 if winsvc: 

920 files = _get_winsvc_config_files(appname, winsvc, files) 

921 

922 return files 

923 

924 

925def _get_primary_config_files(appname, env, env_files_name, default_files): 

926 if not env_files_name: 

927 env_files_name = f"{appname.upper()}_CONFIG_FILES" 

928 

929 files = env.get(env_files_name) 

930 if files is not None: 

931 return files.split(os.pathsep) 

932 

933 if default_files: 

934 if callable(default_files): 

935 files = default_files(appname) or [] 

936 elif isinstance(default_files, str): 

937 files = [default_files] 

938 else: 

939 files = list(default_files) 

940 return [path for path in files if os.path.exists(path)] 

941 

942 files = [] 

943 for path in generic_default_files(appname): 

944 if os.path.exists(path): 

945 files.append(path) 

946 return files 

947 

948 

949def _get_winsvc_config_files(appname, winsvc, files): 

950 config = configparser.ConfigParser() 

951 config.read(files) 

952 section = f"{appname}.config" 

953 if config.has_section(section): 

954 option = f"winsvc.{winsvc}" 

955 if config.has_option(section, option): 

956 # replace file paths with whatever config value says 

957 files = parse_list(config.get(section, option)) 

958 return files 

959 

960 

961def make_config( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals 

962 files=None, 

963 plus_files=None, 

964 appname="wutta", 

965 env_files_name=None, 

966 env_plus_files_name=None, 

967 env=None, 

968 default_files=None, 

969 winsvc=None, 

970 usedb=None, 

971 preferdb=None, 

972 factory=None, 

973 extend=True, 

974 extension_entry_points=None, 

975 **kwargs, 

976): 

977 """ 

978 Make a new config (usually :class:`WuttaConfig`) object, 

979 initialized per the given parameters and (usually) further 

980 modified by all registered config extensions. 

981 

982 This function really does 3 things: 

983 

984 * determine the set of config files to use 

985 * pass those files to config factory 

986 * apply extensions to the resulting config object 

987 

988 Some params are described in :func:`get_config_paths()` since they 

989 are passed as-is to that function for the first step. 

990 

991 :param appname: The :term:`app name` to use as basis for other 

992 things - namely, it affects how config files are located. This 

993 name is also passed to the config factory at which point it 

994 becomes :attr:`~wuttjamaican.conf.WuttaConfig.appname`. 

995 

996 :param usedb: Passed to the config factory; becomes 

997 :attr:`~wuttjamaican.conf.WuttaConfig.usedb`. 

998 

999 :param preferdb: Passed to the config factory; becomes 

1000 :attr:`~wuttjamaican.conf.WuttaConfig.preferdb`. 

1001 

1002 :param factory: Optional factory to use when making the object. 

1003 Default factory is :class:`WuttaConfig`. 

1004 

1005 :param extend: Whether to "auto-extend" the config with all 

1006 registered extensions. 

1007 

1008 As a general rule, ``make_config()`` should only be called 

1009 once, upon app startup. This is because some of the config 

1010 extensions may do things which should only happen one time. 

1011 However if ``extend=False`` is specified, then no extensions 

1012 are invoked, so this may be done multiple times. 

1013 

1014 (Why anyone would need this, is another question..maybe only 

1015 useful for tests.) 

1016 

1017 :param extension_entry_points: Name of the ``setuptools`` entry 

1018 points section, used to identify registered config extensions. 

1019 The default is ``wutta.config.extensions`` unless you override 

1020 ``appname``. 

1021 

1022 :returns: The new config object. 

1023 """ 

1024 

1025 # nb. always show deprecation warnings when making config 

1026 with warnings.catch_warnings(): 

1027 warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt") 

1028 

1029 # collect file paths 

1030 files = get_config_paths( 

1031 files=files, 

1032 plus_files=plus_files, 

1033 appname=appname, 

1034 env_files_name=env_files_name, 

1035 env_plus_files_name=env_plus_files_name, 

1036 env=env, 

1037 default_files=default_files, 

1038 winsvc=winsvc, 

1039 ) 

1040 

1041 # make config object 

1042 if not factory: 

1043 factory = WuttaConfig 

1044 config = factory( 

1045 files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs 

1046 ) 

1047 

1048 # maybe extend config object 

1049 if extend: 

1050 if not extension_entry_points: 

1051 # nb. must not use appname here, entry points must be 

1052 # consistent regardless of appname 

1053 extension_entry_points = "wutta.config.extensions" 

1054 

1055 # apply all registered extensions 

1056 # TODO: maybe let config disable some extensions? 

1057 extensions = load_entry_points(extension_entry_points) 

1058 extensions = [ext() for ext in extensions.values()] 

1059 for extension in extensions: 

1060 log.debug("applying config extension: %s", extension.key) 

1061 extension.configure(config) 

1062 

1063 # let extensions run startup hooks if needed 

1064 for extension in extensions: 

1065 extension.startup(config) 

1066 

1067 # maybe show deprecation warnings from now on 

1068 if config.get_bool( 

1069 f"{config.appname}.show_deprecation_warnings", usedb=False, default=True 

1070 ): 

1071 warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt") 

1072 

1073 return config 

1074 

1075 

1076class WuttaConfigProfile: 

1077 """ 

1078 Base class to represent a configured "profile" in the context of 

1079 some service etc. 

1080 

1081 :param config: App :term:`config object`. 

1082 

1083 :param key: Config key for the profile. 

1084 

1085 Generally each subclass will represent a certain type of config 

1086 profile, and each instance will represent a single profile 

1087 (identified by the ``key``). 

1088 """ 

1089 

1090 def __init__(self, config, key): 

1091 self.config = config 

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

1093 self.key = key 

1094 self.load() 

1095 

1096 @property 

1097 def section(self): 

1098 """ 

1099 The primary config section under which profiles may be 

1100 defined. 

1101 

1102 There is no default; each subclass must declare it. 

1103 

1104 This corresponds to the typical INI file section, for instance 

1105 a section of ``wutta.telemetry`` assumes file contents like: 

1106 

1107 .. code-block:: ini 

1108 

1109 [wutta.telemetry] 

1110 default.submit_url = /nodes/telemetry 

1111 special.submit_url = /nodes/telemetry-special 

1112 """ 

1113 raise NotImplementedError 

1114 

1115 def load(self): 

1116 """ 

1117 Read all relevant settings from config, and assign attributes 

1118 on the profile instance accordingly. 

1119 

1120 There is no default logic but subclass will generally override. 

1121 

1122 While a caller can use :meth:`get_str()` to obtain arbitrary 

1123 config values dynamically, it is often useful for the profile 

1124 to pre-load some config values. This allows "smarter" 

1125 interpretation of config values in some cases, and at least 

1126 ensures common/shared logic. 

1127 

1128 There is no constraint or other guidance in terms of which 

1129 profile attributes might be set by this method. Subclass 

1130 should document if necessary. 

1131 """ 

1132 

1133 def get_str(self, option, **kwargs): 

1134 """ 

1135 Get a string value for the profile, from config. 

1136 

1137 :param option: Name of config option for which to return value. 

1138 

1139 This just calls :meth:`~WuttaConfig.get()` on the config 

1140 object, but for a particular setting name which it composes 

1141 dynamically. 

1142 

1143 Assuming a config file like: 

1144 

1145 .. code-block:: ini 

1146 

1147 [wutta.telemetry] 

1148 default.submit_url = /nodes/telemetry 

1149 

1150 Then a ``default`` profile under the ``wutta.telemetry`` 

1151 section would effectively have a ``submit_url`` option:: 

1152 

1153 class TelemetryProfile(WuttaConfigProfile): 

1154 section = "wutta.telemetry" 

1155 

1156 profile = TelemetryProfile("default") 

1157 url = profile.get_str("submit_url") 

1158 """ 

1159 return self.config.get(f"{self.section}.{self.key}.{option}", **kwargs)