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
« 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"""
27# pylint: disable=too-many-lines
29import configparser
30import logging
31import logging.config
32import os
33import sys
34import tempfile
35import warnings
37import config as configuration
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
48log = logging.getLogger(__name__)
51class WuttaConfig: # pylint: disable=too-many-instance-attributes
52 """
53 Configuration class for Wutta Framework
55 A single instance of this class is typically created on app
56 startup, by calling :func:`make_config()`.
58 The global config object is mainly responsible for providing
59 config values to the app, via :meth:`get()` and similar methods.
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:
65 * settings table in the DB
66 * one or more INI files
67 * "defaults" provided by app logic
69 :param files: Optional list of file paths from which to read
70 config values.
72 :param defaults: Optional dict of initial values to use as
73 defaults. This gets converted to :attr:`defaults` during
74 construction.
76 :param appname: Value to assign for :attr:`appname`.
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()`.
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()`.
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.
90 Attributes available on the config instance:
92 .. attribute:: appname
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.
98 For instance the default ``appname`` value is ``'wutta'`` which
99 means a sample config file might look like:
101 .. code-block:: ini
103 [wutta]
104 app.handler = wuttjamaican.app:AppHandler
106 [wutta.db]
107 default.url = sqlite://
109 But if the ``appname`` value is e.g. ``'rattail'`` then the
110 sample config should instead look like:
112 .. code-block:: ini
114 [rattail]
115 app.handler = wuttjamaican.app:AppHandler
117 [rattail.db]
118 default.url = sqlite://
120 .. attribute:: configuration
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.
128 .. attribute:: defaults
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()`.
136 .. attribute:: default_app_handler_spec
138 Spec string for the default app handler, if config does not
139 specify to use another.
141 The true default for this is ``'wuttjamaican.app:AppHandler'``
142 (aka. :class:`~wuttjamaican.app.AppHandler`).
144 .. attribute:: default_engine_maker_spec
146 Spec string for the default engine maker function, if config
147 does not specify to use another.
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()`).
153 .. attribute:: files_read
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.
160 .. attribute:: usedb
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:
166 .. code-block:: ini
168 [wutta.config]
169 usedb = true
171 See also :ref:`where-config-settings-come-from`.
173 .. attribute:: preferdb
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``.
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.
184 .. code-block:: ini
186 [wutta.config]
187 usedb = true
188 preferdb = true
190 See also :ref:`where-config-settings-come-from`.
191 """
193 _app = None
194 default_app_handler_spec = "wuttjamaican.app:AppHandler"
195 default_engine_maker_spec = "wuttjamaican.db.conf:make_engine_from_config"
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 = []
209 # read all files requested
210 self.files_read = []
211 for path in files or []:
212 self._load_ini_configs(path, configs, require=True)
214 # add config for use w/ setdefault()
215 self.defaults = configuration.Configuration(defaults or {})
216 configs.append(self.defaults)
218 # master config set
219 self.configuration = configuration.ConfigurationSet(*configs)
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()
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 )
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 )
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)
263 log.debug("config files read: %s", self.files_read)
265 def _load_ini_configs(self, path, configs, require=True):
266 path = os.path.abspath(path)
268 # no need to read a file twice; its first appearance sets priority
269 if path in self.files_read:
270 return
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
280 # write config to temp file
281 temp_path = self._write_temp_config_file(config)
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)
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)
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)
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))
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)
317 return temp_path
319 def get_prioritized_files(self):
320 """
321 Returns list of config files in order of priority.
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
328 def setdefault(self, key, value):
329 """
330 Establish a default config value for the given key.
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.
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)
344 # get current value, sans db
345 return self.get(key, usedb=False)
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.
362 .. warning::
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:
369 .. code-block:: ini
371 [foo]
372 bar = 1
373 bar.baz = 2
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::
379 {'baz': '2'}
381 And similarly, if you invoke ``config.get('foo')`` then
382 the return value "should be" the dict::
384 {'bar': '1',
385 'bar.baz': '2'}
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).
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.
402 :param key: String key for which value should be returned.
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.
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``).
412 Note that it is an error to specify a default value if you
413 also specify ``require=True``.
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.
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.
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.
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.
435 :param session: Optional SQLAlchemy session to use for DB lookups.
436 NOTE: This param is not yet implemented; currently ignored.
438 :param \\**kwargs: Any remaining kwargs are passed as-is to
439 the :meth:`get_from_db()` call, if applicable.
441 :returns: Value as string.
443 """
444 if require and default is not UNSPECIFIED:
445 raise ValueError("must not specify default value when require=True")
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
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
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
469 if not ignore_ambiguous:
470 log.warning("ambiguous config key '%s' returns: %s", key, value)
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
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}")
483 # give the default value if specified
484 if default is not UNSPECIFIED:
485 return default
487 return None
489 def get_from_db(self, key, session=None, **kwargs):
490 """
491 Retrieve a config value from database settings table.
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)
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::
505 config.get('foo', require=True)
507 config.require('foo')
508 """
509 kwargs["require"] = True
510 return self.get(*args, **kwargs)
512 def get_bool(self, *args, **kwargs):
513 """
514 Retrieve a boolean value from config.
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)
522 def get_int(self, *args, **kwargs):
523 """
524 Retrieve an integer value from config.
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
535 def get_list(self, *args, **kwargs):
536 """
537 Retrieve a list value from config.
539 Accepts same params as :meth:`get()` but if a value is found,
540 it will be coerced to list via :meth:`parse_list()`.
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
550 def get_dict(self, prefix):
551 """
552 Retrieve a particular group of values, as a dictionary.
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``.
558 For example given this config file:
560 .. code-block:: ini
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
568 One can get the "dict" for SQLAlchemy engine config via::
570 config.get_dict('wutta.db')
572 And the dict would look like::
574 {'keys': 'default, host',
575 'default.url': 'sqlite:///tmp/default.sqlite',
576 'host.url': 'sqlite:///tmp/host.sqlite',
577 'host.pool_pre_ping': 'true'}
579 :param prefix: String prefix corresponding to a subsection of
580 the config.
582 :returns: Dictionary containing the config subsection.
583 """
584 try:
585 values = self.configuration[prefix]
586 except KeyError:
587 return {}
589 return values.as_dict()
591 def parse_bool(self, value):
592 """
593 Convenience wrapper for
594 :func:`wuttjamaican.util.parse_bool()`.
595 """
596 return parse_bool(value)
598 def parse_list(self, value):
599 """
600 Convenience wrapper for
601 :func:`wuttjamaican.util.parse_list()`.
602 """
603 return parse_list(value)
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)
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)
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
638 def get_app(self):
639 """
640 Returns the global :class:`~wuttjamaican.app.AppHandler`
641 instance, creating it if necessary.
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
656 def get_engine_maker(self):
657 """
658 Returns a callable to be used for constructing SQLAlchemy
659 engines fromc config.
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)
667 def production(self):
668 """
669 Returns boolean indicating whether the app is running in
670 production mode.
672 This value may be set e.g. in config file:
674 .. code-block:: ini
676 [wutta]
677 production = true
678 """
679 return self.get_bool(f"{self.appname}.production", default=False)
682class WuttaConfigExtension:
683 """
684 Base class for all :term:`config extensions <config extension>`.
685 """
687 key = None
689 def __repr__(self):
690 return f"WuttaConfigExtension(key={self.key})"
692 def configure(self, config):
693 """
694 Subclass should override this method, to extend the config
695 object in any way necessary.
696 """
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.
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 """
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.
716 :param appname: App name to be used as basis for default filenames.
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 []
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 ]
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 ]
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()`.
780 In short, the files to be used are determined by typical priority:
782 * function params - ``files`` and ``plus_files``
783 * environment variables - e.g. ``WUTTA_CONFIG_FILES``
784 * app defaults - e.g. :func:`generic_default_files()`
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.
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.
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.
799 :param plus_files: Explicit set of "plus" config files. Same
800 rules apply here as for the ``files`` param.
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``.
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``.
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``.
815 :param env: Optional environment dict; if not specified
816 ``os.environ`` is used.
818 :param default_files: Optional lookup for "default" file paths.
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.
824 If not specified, :func:`generic_default_files()` will be used
825 for the lookup.
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::
831 mydefaults = '/tmp/something.conf'
833 mydefaults = [
834 '/tmp/something.conf',
835 '/tmp/else.conf',
836 ]
838 def mydefaults(appname):
839 return [
840 f"/tmp/{appname}.conf",
841 f"/tmp/{appname}.ini",
842 ]
844 files = get_config_paths(default_files=mydefaults)
846 :param winsvc: Optional internal name of the Windows service for
847 which the config object is being made.
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.
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.
859 So for example on Windows you might have a config file at
860 ``C:\\ProgramData\\rattail\\rattail.conf`` with contents:
862 .. code-block:: ini
864 [rattail.config]
865 winsvc.RattailFileMonitor = C:\\ProgramData\\rattail\\filemon.conf
867 And then ``C:\\ProgramData\\rattail\\filemon.conf`` would have
868 the actual config for the filemon service.
870 When the service starts it calls::
872 make_config(winsvc='RattailFileMonitor')
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::
880 make_config(files=[r'C:\\ProgramData\\rattail\\filemon.conf'])
882 :returns: List of file paths.
883 """
884 if env is None:
885 env = os.environ
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)
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"
900 plus_files = env.get(env_plus_files_name)
901 if plus_files is not None:
902 plus_files = plus_files.split(os.pathsep)
904 else:
905 plus_files = []
907 elif isinstance(plus_files, str):
908 plus_files = [plus_files]
909 else:
910 plus_files = list(plus_files)
912 # combine all files
913 files.extend(plus_files)
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)
923 return files
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"
930 files = env.get(env_files_name)
931 if files is not None:
932 return files.split(os.pathsep)
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)]
943 files = []
944 for path in generic_default_files(appname):
945 if os.path.exists(path):
946 files.append(path)
947 return files
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
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.
983 This function really does 3 things:
985 * determine the set of config files to use
986 * pass those files to config factory
987 * apply extensions to the resulting config object
989 Some params are described in :func:`get_config_paths()` since they
990 are passed as-is to that function for the first step.
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`.
997 :param usedb: Passed to the config factory; becomes
998 :attr:`~wuttjamaican.conf.WuttaConfig.usedb`.
1000 :param preferdb: Passed to the config factory; becomes
1001 :attr:`~wuttjamaican.conf.WuttaConfig.preferdb`.
1003 :param factory: Optional factory to use when making the object.
1004 Default factory is :class:`WuttaConfig`.
1006 :param extend: Whether to "auto-extend" the config with all
1007 registered extensions.
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.
1015 (Why anyone would need this, is another question..maybe only
1016 useful for tests.)
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``.
1023 :returns: The new config object.
1024 """
1026 # nb. always show deprecation warnings when making config
1027 with warnings.catch_warnings():
1028 warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt")
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 )
1042 # make config object
1043 if not factory:
1044 factory = WuttaConfig
1045 config = factory(
1046 files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs
1047 )
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"
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)
1064 # let extensions run startup hooks if needed
1065 for extension in extensions:
1066 extension.startup(config)
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")
1074 return config
1077class WuttaConfigProfile:
1078 """
1079 Base class to represent a configured "profile" in the context of
1080 some service etc.
1082 :param config: App :term:`config object`.
1084 :param key: Config key for the profile.
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 """
1091 def __init__(self, config, key):
1092 self.config = config
1093 self.app = self.config.get_app()
1094 self.key = key
1095 self.load()
1097 @property
1098 def section(self):
1099 """
1100 The primary config section under which profiles may be
1101 defined.
1103 There is no default; each subclass must declare it.
1105 This corresponds to the typical INI file section, for instance
1106 a section of ``wutta.telemetry`` assumes file contents like:
1108 .. code-block:: ini
1110 [wutta.telemetry]
1111 default.submit_url = /nodes/telemetry
1112 special.submit_url = /nodes/telemetry-special
1113 """
1114 raise NotImplementedError
1116 def load(self):
1117 """
1118 Read all relevant settings from config, and assign attributes
1119 on the profile instance accordingly.
1121 There is no default logic but subclass will generally override.
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.
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 """
1134 def get_str(self, option, **kwargs):
1135 """
1136 Get a string value for the profile, from config.
1138 :param option: Name of config option for which to return value.
1140 This just calls :meth:`~WuttaConfig.get()` on the config
1141 object, but for a particular setting name which it composes
1142 dynamically.
1144 Assuming a config file like:
1146 .. code-block:: ini
1148 [wutta.telemetry]
1149 default.submit_url = /nodes/telemetry
1151 Then a ``default`` profile under the ``wutta.telemetry``
1152 section would effectively have a ``submit_url`` option::
1154 class TelemetryProfile(WuttaConfigProfile):
1155 section = "wutta.telemetry"
1157 profile = TelemetryProfile("default")
1158 url = profile.get_str("submit_url")
1159 """
1160 return self.config.get(f"{self.section}.{self.key}.{option}", **kwargs)