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

238 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-12-18 23:41 -0600

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

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

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

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

12# later version. 

13# 

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

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

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

17# more details. 

18# 

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

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

21# 

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

23""" 

24WuttJamaican - app configuration 

25""" 

26 

27import configparser 

28import importlib 

29import logging 

30import logging.config 

31import os 

32import sys 

33import tempfile 

34 

35import config as configuration 

36 

37from wuttjamaican.util import (load_entry_points, load_object, 

38 parse_bool, parse_list, 

39 UNSPECIFIED) 

40from wuttjamaican.exc import ConfigurationError 

41 

42 

43log = logging.getLogger(__name__) 

44 

45 

46class WuttaConfig: 

47 """ 

48 Configuration class for Wutta Framework 

49 

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

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

52 

53 The global config object is mainly responsible for providing 

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

55 

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

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

58 lookup is like: 

59 

60 * settings table in the DB 

61 * one or more INI files 

62 * "defaults" provided by app logic 

63 

64 :param files: List of file paths from which to read config values. 

65 

66 :param defaults: Initial values to use as defaults. This gets 

67 converted to :attr:`defaults` during construction. 

68 

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

70 

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

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

73 calling :meth:`get()`. 

74 

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

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

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

78 

79 :param configure_logging: Flag indicating whether logging should 

80 be configured during object construction. If not specified, 

81 the config values will determine behavior. 

82 

83 Attributes available on the config instance: 

84 

85 .. attribute:: appname 

86 

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

88 basis for various config settings and will therefore determine 

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

90 

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

92 means a sample config file might look like: 

93 

94 .. code-block:: ini 

95 

96 [wutta] 

97 app.handler = wuttjamaican.app:AppHandler 

98 

99 [wutta.db] 

100 default.url = sqlite:// 

101 

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

103 sample config should instead look like: 

104 

105 .. code-block:: ini 

106 

107 [rattail] 

108 app.handler = wuttjamaican.app:AppHandler 

109 

110 [rattail.db] 

111 default.url = sqlite:// 

112 

113 .. attribute:: configuration 

114 

115 Reference to the 

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

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

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

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

120 

121 .. attribute:: defaults 

122 

123 Reference to the 

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

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

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

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

128 

129 .. attribute:: default_app_handler_spec 

130 

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

132 specify to use another. 

133 

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

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

136 

137 .. attribute:: default_engine_maker_spec 

138 

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

140 does not specify to use another. 

141 

142 The true default for this is 

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

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

145 

146 .. attribute:: files_read 

147 

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

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

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

151 first file with the value wins. 

152 

153 .. attribute:: usedb 

154 

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

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

157 enabled via config file: 

158 

159 .. code-block:: ini 

160 

161 [wutta.config] 

162 usedb = true 

163 

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

165 

166 .. attribute:: preferdb 

167 

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

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

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

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

172 

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

174 settings table is updated, it will immediately affect app 

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

176 

177 .. code-block:: ini 

178 

179 [wutta.config] 

180 usedb = true 

181 preferdb = true 

182 

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

184 """ 

185 default_app_handler_spec = 'wuttjamaican.app:AppHandler' 

186 default_engine_maker_spec = 'wuttjamaican.db.conf:make_engine_from_config' 

187 

188 def __init__( 

189 self, 

190 files=[], 

191 defaults={}, 

192 appname='wutta', 

193 usedb=None, 

194 preferdb=None, 

195 configure_logging=None, 

196 ): 

197 self.appname = appname 

198 configs = [] 

199 

200 # read all files requested 

201 self.files_read = [] 

202 for path in files: 

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

204 

205 # add config for use w/ setdefault() 

206 self.defaults = configuration.Configuration(defaults) 

207 configs.append(self.defaults) 

208 

209 # master config set 

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

211 

212 # establish logging 

213 if configure_logging is None: 

214 configure_logging = self.get_bool(f'{self.appname}.config.configure_logging', 

215 default=False, usedb=False) 

216 if configure_logging: 

217 self._configure_logging() 

218 

219 # usedb flag 

220 self.usedb = usedb 

221 if self.usedb is None: 

222 self.usedb = self.get_bool(f'{self.appname}.config.usedb', 

223 default=False, usedb=False) 

224 

225 # preferdb flag 

226 self.preferdb = preferdb 

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

228 self.preferdb = self.get_bool(f'{self.appname}.config.preferdb', 

229 default=False, usedb=False) 

230 

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

232 try: 

233 from wuttjamaican.db import Session, get_engines 

234 except ImportError: 

235 if self.usedb: 

236 log.warning("config created with `usedb = True`, but can't import " 

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

238 exc_info=True) 

239 self.usedb = False 

240 self.preferdb = False 

241 else: 

242 self.appdb_engines = get_engines(self, f'{self.appname}.db') 

243 self.appdb_engine = self.appdb_engines.get('default') 

244 Session.configure(bind=self.appdb_engine) 

245 

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

247 

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

249 path = os.path.abspath(path) 

250 

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

252 if path in self.files_read: 

253 return 

254 

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

256 here = os.path.dirname(path) 

257 config = configparser.ConfigParser(defaults={'here': here, '__file__': path}) 

258 if not config.read(path): 

259 if require: 

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

261 return 

262 

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

264 temp_config = configparser.RawConfigParser() 

265 for section in config.sections(): 

266 temp_config.add_section(section) 

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

268 raw = section.startswith('formatter_') 

269 for option in config.options(section): 

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

271 

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

273 fd, temp_path = tempfile.mkstemp(suffix='.ini') 

274 os.close(fd) 

275 with open(temp_path, 'wt') as f: 

276 temp_config.write(f) 

277 

278 # and finally, load that into our main config 

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

280 configs.append(config) 

281 self.files_read.append(path) 

282 

283 # bring in any "required" files 

284 requires = config.get(f'{self.appname}.config.require') 

285 if requires: 

286 for path in self.parse_list(requires): 

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

288 

289 # bring in any "included" files 

290 includes = config.get(f'{self.appname}.config.include') 

291 if includes: 

292 for path in self.parse_list(includes): 

293 self._load_ini_configs(path, configs, require=False) 

294 

295 def get_prioritized_files(self): 

296 """ 

297 Returns list of config files in order of priority. 

298 

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

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

301 """ 

302 return self.files_read 

303 

304 def setdefault( 

305 self, 

306 key, 

307 value): 

308 """ 

309 Establish a default config value for the given key. 

310 

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

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

313 the default and subsequent calls have no effect. 

314 

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

316 various reasons this method may not be able to lookup 

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

318 determine the value per INI files + config defaults. 

319 """ 

320 # set default value, if not already set 

321 self.defaults.setdefault(key, value) 

322 

323 # get current value, sans db 

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

325 

326 def get( 

327 self, 

328 key, 

329 default=UNSPECIFIED, 

330 require=False, 

331 ignore_ambiguous=False, 

332 message=None, 

333 usedb=None, 

334 preferdb=None, 

335 session=None, 

336 ): 

337 """ 

338 Retrieve a string value from config. 

339 

340 .. warning:: 

341 

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

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

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

345 simple value. For instance with this config file: 

346 

347 .. code-block:: ini 

348 

349 [foo] 

350 bar = 1 

351 bar.baz = 2 

352 

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

354 is somewhat ambiguous. At first glance it should return 

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

356 

357 {'baz': '2'} 

358 

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

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

361 

362 {'bar': '1', 

363 'bar.baz': '2'} 

364 

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

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

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

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

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

370 

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

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

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

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

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

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

377 overshadows it, and this method will only return the 

378 default value in lieu of any dict. 

379 

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

381 

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

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

384 will be assumed. 

385 

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

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

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

389 

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

391 also specify ``require=True``. 

392 

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

394 warning if an ambiguous value is detected (as described 

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

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

397 there for a reason. 

398 

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

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

401 a default error message will be generated. 

402 

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

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

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

406 the behavior. 

407 

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

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

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

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

412 

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

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

415 

416 :returns: Value as string. 

417 

418 """ 

419 if require and default is not UNSPECIFIED: 

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

421 

422 # should we use/prefer db? 

423 if usedb is None: 

424 usedb = self.usedb 

425 if usedb and preferdb is None: 

426 preferdb = self.preferdb 

427 

428 # read from db first if so requested 

429 if usedb and preferdb: 

430 value = self.get_from_db(key, session=session) 

431 if value is not None: 

432 return value 

433 

434 # read from defaults + INI files 

435 value = self.configuration.get(key) 

436 if value is not None: 

437 

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

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

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

441 # such a config subset. 

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

443 return value 

444 

445 if not ignore_ambiguous: 

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

447 

448 # read from db last if so requested 

449 if usedb and not preferdb: 

450 value = self.get_from_db(key, session=session) 

451 if value is not None: 

452 return value 

453 

454 # raise error if required value not found 

455 if require: 

456 message = message or "missing config" 

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

458 

459 # give the default value if specified 

460 if default is not UNSPECIFIED: 

461 return default 

462 

463 def get_from_db(self, key, session=None): 

464 """ 

465 Retrieve a config value from database settings table. 

466 

467 This is a convenience wrapper around 

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

469 """ 

470 app = self.get_app() 

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

472 return app.get_setting(s, key) 

473 

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

475 """ 

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

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

478 

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

480 

481 config.require('foo') 

482 """ 

483 kwargs['require'] = True 

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

485 

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

487 """ 

488 Retrieve a boolean value from config. 

489 

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

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

492 """ 

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

494 return self.parse_bool(value) 

495 

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

497 """ 

498 Retrieve an integer value from config. 

499 

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

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

502 constructor. 

503 """ 

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

505 if value is not None: 

506 return int(value) 

507 

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

509 """ 

510 Retrieve a list value from config. 

511 

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

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

514 

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

516 value, returns ``None``. 

517 """ 

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

519 if value is not None: 

520 return self.parse_list(value) 

521 

522 def get_dict(self, prefix): 

523 """ 

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

525 

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

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

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

529 

530 For example given this config file: 

531 

532 .. code-block:: ini 

533 

534 [wutta.db] 

535 keys = default, host 

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

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

538 host.pool_pre_ping = true 

539 

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

541 

542 config.get_dict('wutta.db') 

543 

544 And the dict would look like:: 

545 

546 {'keys': 'default, host', 

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

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

549 'host.pool_pre_ping': 'true'} 

550 

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

552 the config. 

553 

554 :returns: Dictionary containing the config subsection. 

555 """ 

556 try: 

557 values = self.configuration[prefix] 

558 except KeyError: 

559 return {} 

560 

561 return values.as_dict() 

562 

563 def parse_bool(self, value): 

564 """ 

565 Convenience wrapper for 

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

567 """ 

568 return parse_bool(value) 

569 

570 def parse_list(self, value): 

571 """ 

572 Convenience wrapper for 

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

574 """ 

575 return parse_list(value) 

576 

577 def _configure_logging(self): 

578 """ 

579 This will save the current config parser defaults to a 

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

581 standard logging module. 

582 """ 

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

584 path = self._write_logging_config_file() 

585 try: 

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

587 except configparser.NoSectionError as error: 

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

589 else: 

590 log.debug("configured logging") 

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

592 finally: 

593 os.remove(path) 

594 

595 def _write_logging_config_file(self): 

596 

597 # load all current values into configparser 

598 parser = configparser.RawConfigParser() 

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

600 parser.add_section(section) 

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

602 parser.set(section, option, value) 

603 

604 # write INI file and return path 

605 fd, path = tempfile.mkstemp(suffix='.conf') 

606 os.close(fd) 

607 with open(path, 'wt') as f: 

608 parser.write(f) 

609 return path 

610 

611 def get_app(self): 

612 """ 

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

614 instance, creating it if necessary. 

615 

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

617 """ 

618 if not hasattr(self, '_app'): 

619 spec = self.get(f'{self.appname}.app.handler', usedb=False, 

620 default=self.default_app_handler_spec) 

621 factory = load_object(spec) 

622 self._app = factory(self) 

623 return self._app 

624 

625 def get_engine_maker(self): 

626 """ 

627 Returns a callable to be used for constructing SQLAlchemy 

628 engines fromc config. 

629 

630 Which callable is used depends on 

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

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

633 """ 

634 return load_object(self.default_engine_maker_spec) 

635 

636 def production(self): 

637 """ 

638 Returns boolean indicating whether the app is running in 

639 production mode. 

640 

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

642 

643 .. code-block:: ini 

644 

645 [wutta] 

646 production = true 

647 """ 

648 return self.get_bool(f'{self.appname}.production', default=False) 

649 

650 

651class WuttaConfigExtension: 

652 """ 

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

654 """ 

655 key = None 

656 

657 def __repr__(self): 

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

659 

660 def configure(self, config): 

661 """ 

662 Subclass should override this method, to extend the config 

663 object in any way necessary. 

664 """ 

665 

666 def startup(self, config): 

667 """ 

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

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

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

671 

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

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

674 initial settings if needed. 

675 """ 

676 

677 

678def generic_default_files(appname): 

679 """ 

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

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

682 actually exist. 

683 

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

685 

686 :returns: List of default file paths. 

687 """ 

688 if sys.platform == 'win32': 

689 # use pywin32 to fetch official defaults 

690 try: 

691 from win32com.shell import shell, shellcon 

692 except ImportError: 

693 return [] 

694 

695 return [ 

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

697 os.path.join(shell.SHGetSpecialFolderPath( 

698 0, shellcon.CSIDL_APPDATA), appname, f'{appname}.conf'), 

699 os.path.join(shell.SHGetSpecialFolderPath( 

700 0, shellcon.CSIDL_APPDATA), f'{appname}.conf'), 

701 

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

703 os.path.join(shell.SHGetSpecialFolderPath( 

704 0, shellcon.CSIDL_COMMON_APPDATA), appname, f'{appname}.conf'), 

705 os.path.join(shell.SHGetSpecialFolderPath( 

706 0, shellcon.CSIDL_COMMON_APPDATA), f'{appname}.conf'), 

707 ] 

708 

709 # default paths for *nix 

710 return [ 

711 f'{sys.prefix}/app/{appname}.conf', 

712 

713 os.path.expanduser(f'~/.{appname}/{appname}.conf'), 

714 os.path.expanduser(f'~/.{appname}.conf'), 

715 

716 f'/usr/local/etc/{appname}/{appname}.conf', 

717 f'/usr/local/etc/{appname}.conf', 

718 

719 f'/etc/{appname}/{appname}.conf', 

720 f'/etc/{appname}.conf', 

721 ] 

722 

723 

724def get_config_paths( 

725 files=None, 

726 plus_files=None, 

727 appname='wutta', 

728 env_files_name=None, 

729 env_plus_files_name=None, 

730 env=None, 

731 default_files=None, 

732 winsvc=None): 

733 """ 

734 This function determines which files should ultimately be provided 

735 to the config constructor. It is normally called by 

736 :func:`make_config()`. 

737 

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

739 

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

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

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

743 

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

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

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

747 

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

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

750 exist. 

751 

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

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

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

755 force an empty main file set. 

756 

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

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

759 

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

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

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

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

764 

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

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

767 ``WUTTA_CONFIG_FILES`` unless you override ``appname``. 

768 

769 :param env_plus_files_name: Name of the environment variable to 

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

771 ``WUTTA_CONFIG_PLUS_FILES`` unless you override ``appname``. 

772 

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

774 ``os.environ`` is used. 

775 

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

777 

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

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

780 variables yielded anything. 

781 

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

783 for the lookup. 

784 

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

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

787 example any of these could be used:: 

788 

789 mydefaults = '/tmp/something.conf' 

790 

791 mydefaults = [ 

792 '/tmp/something.conf', 

793 '/tmp/else.conf', 

794 ] 

795 

796 def mydefaults(appname): 

797 return [ 

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

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

800 ] 

801 

802 files = get_config_paths(default_files=mydefaults) 

803 

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

805 which the config object is being made. 

806 

807 This is only needed for true Windows services running via 

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

809 the Rattail File Monitor service. 

810 

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

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

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

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

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

816 

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

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

819 

820 .. code-block:: ini 

821 

822 [rattail.config] 

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

824 

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

826 the actual config for the filemon service. 

827 

828 When the service starts it calls:: 

829 

830 make_config(winsvc='RattailFileMonitor') 

831 

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

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

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

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

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

837 

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

839 

840 :returns: List of file paths. 

841 """ 

842 if env is None: 

843 env = os.environ 

844 

845 # first identify any "primary" config files 

846 if files is None: 

847 if not env_files_name: 

848 env_files_name = f'{appname.upper()}_CONFIG_FILES' 

849 

850 files = env.get(env_files_name) 

851 if files is not None: 

852 files = files.split(os.pathsep) 

853 

854 elif default_files: 

855 if callable(default_files): 

856 files = default_files(appname) or [] 

857 elif isinstance(default_files, str): 

858 files = [default_files] 

859 else: 

860 files = list(default_files) 

861 files = [path for path in files 

862 if os.path.exists(path)] 

863 

864 else: 

865 files = [] 

866 for path in generic_default_files(appname): 

867 if os.path.exists(path): 

868 files.append(path) 

869 

870 elif isinstance(files, str): 

871 files = [files] 

872 else: 

873 files = list(files) 

874 

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

876 if plus_files is None: 

877 if not env_plus_files_name: 

878 env_plus_files_name = f'{appname.upper()}_CONFIG_PLUS_FILES' 

879 

880 plus_files = env.get(env_plus_files_name) 

881 if plus_files is not None: 

882 plus_files = plus_files.split(os.pathsep) 

883 

884 else: 

885 plus_files = [] 

886 

887 elif isinstance(plus_files, str): 

888 plus_files = [plus_files] 

889 else: 

890 plus_files = list(plus_files) 

891 

892 # combine all files 

893 files.extend(plus_files) 

894 

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

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

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

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

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

900 if winsvc: 

901 config = configparser.ConfigParser() 

902 config.read(files) 

903 section = f'{appname}.config' 

904 if config.has_section(section): 

905 option = f'winsvc.{winsvc}' 

906 if config.has_option(section, option): 

907 # replace file paths with whatever config value says 

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

909 

910 return files 

911 

912 

913def make_config( 

914 files=None, 

915 plus_files=None, 

916 appname='wutta', 

917 env_files_name=None, 

918 env_plus_files_name=None, 

919 env=None, 

920 default_files=None, 

921 winsvc=None, 

922 usedb=None, 

923 preferdb=None, 

924 factory=None, 

925 extend=True, 

926 extension_entry_points=None, 

927 **kwargs): 

928 """ 

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

930 initialized per the given parameters and (usually) further 

931 modified by all registered config extensions. 

932 

933 This function really does 3 things: 

934 

935 * determine the set of config files to use 

936 * pass those files to config factory 

937 * apply extensions to the resulting config object 

938 

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

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

941 

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

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

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

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

946 

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

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

949 

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

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

952 

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

954 Default factory is :class:`WuttaConfig`. 

955 

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

957 registered extensions. 

958 

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

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

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

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

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

964 

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

966 useful for tests.) 

967 

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

969 points section, used to identify registered config extensions. 

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

971 ``appname``. 

972 

973 :returns: The new config object. 

974 """ 

975 # collect file paths 

976 files = get_config_paths( 

977 files=files, 

978 plus_files=plus_files, 

979 appname=appname, 

980 env_files_name=env_files_name, 

981 env_plus_files_name=env_plus_files_name, 

982 env=env, 

983 default_files=default_files, 

984 winsvc=winsvc) 

985 

986 # make config object 

987 if not factory: 

988 factory = WuttaConfig 

989 config = factory(files, appname=appname, 

990 usedb=usedb, preferdb=preferdb, 

991 **kwargs) 

992 

993 # maybe extend config object 

994 if extend: 

995 if not extension_entry_points: 

996 extension_entry_points = f'{appname}.config.extensions' 

997 

998 # apply all registered extensions 

999 # TODO: maybe let config disable some extensions? 

1000 extensions = load_entry_points(extension_entry_points) 

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

1002 for extension in extensions: 

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

1004 extension.configure(config) 

1005 

1006 # let extensions run startup hooks if needed 

1007 for extension in extensions: 

1008 extension.startup(config) 

1009 

1010 return config