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

268 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 12:31 -0500

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

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

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

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

12# later version. 

13# 

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

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

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

17# more details. 

18# 

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

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

21# 

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

23""" 

24WuttJamaican - app configuration 

25""" 

26 

27# pylint: disable=too-many-lines 

28 

29import configparser 

30import logging 

31import logging.config 

32import os 

33import sys 

34import tempfile 

35import warnings 

36 

37import config as configuration 

38 

39from wuttjamaican.util import ( 

40 load_entry_points, 

41 load_object, 

42 parse_bool, 

43 parse_list, 

44 UNSPECIFIED, 

45) 

46from wuttjamaican.exc import ConfigurationError 

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 log.debug("app handler spec is %s", spec) 

653 self._app = factory(self) 

654 return self._app 

655 

656 def get_engine_maker(self): 

657 """ 

658 Returns a callable to be used for constructing SQLAlchemy 

659 engines fromc config. 

660 

661 Which callable is used depends on 

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

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

664 """ 

665 return load_object(self.default_engine_maker_spec) 

666 

667 def production(self): 

668 """ 

669 Returns boolean indicating whether the app is running in 

670 production mode. 

671 

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

673 

674 .. code-block:: ini 

675 

676 [wutta] 

677 production = true 

678 """ 

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

680 

681 

682class WuttaConfigExtension: 

683 """ 

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

685 """ 

686 

687 key = None 

688 

689 def __repr__(self): 

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

691 

692 def configure(self, config): 

693 """ 

694 Subclass should override this method, to extend the config 

695 object in any way necessary. 

696 """ 

697 

698 def startup(self, config): 

699 """ 

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

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

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

703 

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

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

706 initial settings if needed. 

707 """ 

708 

709 

710def generic_default_files(appname): 

711 """ 

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

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

714 actually exist. 

715 

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

717 

718 :returns: List of default file paths. 

719 """ 

720 if sys.platform == "win32": 

721 # use pywin32 to fetch official defaults 

722 try: 

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

724 shell, 

725 shellcon, 

726 ) 

727 except ImportError: 

728 return [] 

729 

730 return [ 

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

732 os.path.join( 

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

734 appname, 

735 f"{appname}.conf", 

736 ), 

737 os.path.join( 

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

739 f"{appname}.conf", 

740 ), 

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

742 os.path.join( 

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

744 appname, 

745 f"{appname}.conf", 

746 ), 

747 os.path.join( 

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

749 f"{appname}.conf", 

750 ), 

751 ] 

752 

753 # default paths for *nix 

754 return [ 

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

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

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

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

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

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

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

762 ] 

763 

764 

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

766 files=None, 

767 plus_files=None, 

768 appname="wutta", 

769 env_files_name=None, 

770 env_plus_files_name=None, 

771 env=None, 

772 default_files=None, 

773 winsvc=None, 

774): 

775 """ 

776 This function determines which files should ultimately be provided 

777 to the config constructor. It is normally called by 

778 :func:`make_config()`. 

779 

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

781 

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

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

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

785 

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

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

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

789 

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

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

792 exist. 

793 

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

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

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

797 force an empty main file set. 

798 

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

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

801 

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

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

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

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

806 

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

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

809 ``WUTTA_CONFIG_FILES`` unless you override ``appname``. 

810 

811 :param env_plus_files_name: Name of the environment variable to 

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

813 ``WUTTA_CONFIG_PLUS_FILES`` unless you override ``appname``. 

814 

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

816 ``os.environ`` is used. 

817 

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

819 

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

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

822 variables yielded anything. 

823 

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

825 for the lookup. 

826 

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

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

829 example any of these could be used:: 

830 

831 mydefaults = '/tmp/something.conf' 

832 

833 mydefaults = [ 

834 '/tmp/something.conf', 

835 '/tmp/else.conf', 

836 ] 

837 

838 def mydefaults(appname): 

839 return [ 

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

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

842 ] 

843 

844 files = get_config_paths(default_files=mydefaults) 

845 

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

847 which the config object is being made. 

848 

849 This is only needed for true Windows services running via 

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

851 the Rattail File Monitor service. 

852 

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

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

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

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

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

858 

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

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

861 

862 .. code-block:: ini 

863 

864 [rattail.config] 

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

866 

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

868 the actual config for the filemon service. 

869 

870 When the service starts it calls:: 

871 

872 make_config(winsvc='RattailFileMonitor') 

873 

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

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

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

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

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

879 

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

881 

882 :returns: List of file paths. 

883 """ 

884 if env is None: 

885 env = os.environ 

886 

887 # first identify any "primary" config files 

888 if files is None: 

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

890 elif isinstance(files, str): 

891 files = [files] 

892 else: 

893 files = list(files) 

894 

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

896 if plus_files is None: 

897 if not env_plus_files_name: 

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

899 

900 plus_files = env.get(env_plus_files_name) 

901 if plus_files is not None: 

902 plus_files = plus_files.split(os.pathsep) 

903 

904 else: 

905 plus_files = [] 

906 

907 elif isinstance(plus_files, str): 

908 plus_files = [plus_files] 

909 else: 

910 plus_files = list(plus_files) 

911 

912 # combine all files 

913 files.extend(plus_files) 

914 

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

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

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

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

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

920 if winsvc: 

921 files = _get_winsvc_config_files(appname, winsvc, files) 

922 

923 return files 

924 

925 

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

927 if not env_files_name: 

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

929 

930 files = env.get(env_files_name) 

931 if files is not None: 

932 return files.split(os.pathsep) 

933 

934 if default_files: 

935 if callable(default_files): 

936 files = default_files(appname) or [] 

937 elif isinstance(default_files, str): 

938 files = [default_files] 

939 else: 

940 files = list(default_files) 

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

942 

943 files = [] 

944 for path in generic_default_files(appname): 

945 if os.path.exists(path): 

946 files.append(path) 

947 return files 

948 

949 

950def _get_winsvc_config_files(appname, winsvc, files): 

951 config = configparser.ConfigParser() 

952 config.read(files) 

953 section = f"{appname}.config" 

954 if config.has_section(section): 

955 option = f"winsvc.{winsvc}" 

956 if config.has_option(section, option): 

957 # replace file paths with whatever config value says 

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

959 return files 

960 

961 

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

963 files=None, 

964 plus_files=None, 

965 appname="wutta", 

966 env_files_name=None, 

967 env_plus_files_name=None, 

968 env=None, 

969 default_files=None, 

970 winsvc=None, 

971 usedb=None, 

972 preferdb=None, 

973 factory=None, 

974 extend=True, 

975 extension_entry_points=None, 

976 **kwargs, 

977): 

978 """ 

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

980 initialized per the given parameters and (usually) further 

981 modified by all registered config extensions. 

982 

983 This function really does 3 things: 

984 

985 * determine the set of config files to use 

986 * pass those files to config factory 

987 * apply extensions to the resulting config object 

988 

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

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

991 

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

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

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

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

996 

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

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

999 

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

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

1002 

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

1004 Default factory is :class:`WuttaConfig`. 

1005 

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

1007 registered extensions. 

1008 

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

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

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

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

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

1014 

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

1016 useful for tests.) 

1017 

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

1019 points section, used to identify registered config extensions. 

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

1021 ``appname``. 

1022 

1023 :returns: The new config object. 

1024 """ 

1025 

1026 # nb. always show deprecation warnings when making config 

1027 with warnings.catch_warnings(): 

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

1029 

1030 # collect file paths 

1031 files = get_config_paths( 

1032 files=files, 

1033 plus_files=plus_files, 

1034 appname=appname, 

1035 env_files_name=env_files_name, 

1036 env_plus_files_name=env_plus_files_name, 

1037 env=env, 

1038 default_files=default_files, 

1039 winsvc=winsvc, 

1040 ) 

1041 

1042 # make config object 

1043 if not factory: 

1044 factory = WuttaConfig 

1045 config = factory( 

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

1047 ) 

1048 

1049 # maybe extend config object 

1050 if extend: 

1051 if not extension_entry_points: 

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

1053 # consistent regardless of appname 

1054 extension_entry_points = "wutta.config.extensions" 

1055 

1056 # apply all registered extensions 

1057 # TODO: maybe let config disable some extensions? 

1058 extensions = load_entry_points(extension_entry_points) 

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

1060 for extension in extensions: 

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

1062 extension.configure(config) 

1063 

1064 # let extensions run startup hooks if needed 

1065 for extension in extensions: 

1066 extension.startup(config) 

1067 

1068 # maybe show deprecation warnings from now on 

1069 if config.get_bool( 

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

1071 ): 

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

1073 

1074 return config 

1075 

1076 

1077class WuttaConfigProfile: 

1078 """ 

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

1080 some service etc. 

1081 

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

1083 

1084 :param key: Config key for the profile. 

1085 

1086 Generally each subclass will represent a certain type of config 

1087 profile, and each instance will represent a single profile 

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

1089 """ 

1090 

1091 def __init__(self, config, key): 

1092 self.config = config 

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

1094 self.key = key 

1095 self.load() 

1096 

1097 @property 

1098 def section(self): 

1099 """ 

1100 The primary config section under which profiles may be 

1101 defined. 

1102 

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

1104 

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

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

1107 

1108 .. code-block:: ini 

1109 

1110 [wutta.telemetry] 

1111 default.submit_url = /nodes/telemetry 

1112 special.submit_url = /nodes/telemetry-special 

1113 """ 

1114 raise NotImplementedError 

1115 

1116 def load(self): 

1117 """ 

1118 Read all relevant settings from config, and assign attributes 

1119 on the profile instance accordingly. 

1120 

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

1122 

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

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

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

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

1127 ensures common/shared logic. 

1128 

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

1130 profile attributes might be set by this method. Subclass 

1131 should document if necessary. 

1132 """ 

1133 

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

1135 """ 

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

1137 

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

1139 

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

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

1142 dynamically. 

1143 

1144 Assuming a config file like: 

1145 

1146 .. code-block:: ini 

1147 

1148 [wutta.telemetry] 

1149 default.submit_url = /nodes/telemetry 

1150 

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

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

1153 

1154 class TelemetryProfile(WuttaConfigProfile): 

1155 section = "wutta.telemetry" 

1156 

1157 profile = TelemetryProfile("default") 

1158 url = profile.get_str("submit_url") 

1159 """ 

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