Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/conf.py: 100%
267 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-28 15:05 -0600
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-28 15:05 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2025 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24WuttJamaican - app configuration
25"""
26# pylint: disable=too-many-lines
28import configparser
29import logging
30import logging.config
31import os
32import sys
33import tempfile
34import warnings
36import config as configuration
38from wuttjamaican.util import (
39 load_entry_points,
40 load_object,
41 parse_bool,
42 parse_list,
43 UNSPECIFIED,
44)
45from wuttjamaican.exc import ConfigurationError
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 self._app = factory(self)
653 return self._app
655 def get_engine_maker(self):
656 """
657 Returns a callable to be used for constructing SQLAlchemy
658 engines fromc config.
660 Which callable is used depends on
661 :attr:`default_engine_maker_spec` but by default will be
662 :func:`wuttjamaican.db.conf.make_engine_from_config()`.
663 """
664 return load_object(self.default_engine_maker_spec)
666 def production(self):
667 """
668 Returns boolean indicating whether the app is running in
669 production mode.
671 This value may be set e.g. in config file:
673 .. code-block:: ini
675 [wutta]
676 production = true
677 """
678 return self.get_bool(f"{self.appname}.production", default=False)
681class WuttaConfigExtension:
682 """
683 Base class for all :term:`config extensions <config extension>`.
684 """
686 key = None
688 def __repr__(self):
689 return f"WuttaConfigExtension(key={self.key})"
691 def configure(self, config):
692 """
693 Subclass should override this method, to extend the config
694 object in any way necessary.
695 """
697 def startup(self, config):
698 """
699 This method is called after the config object is fully created
700 and all extensions have been applied, i.e. after
701 :meth:`configure()` has been called for each extension.
703 At this point the config *settings* for the running app should
704 be settled, and each extension is then allowed to act on those
705 initial settings if needed.
706 """
709def generic_default_files(appname):
710 """
711 Returns a list of default file paths which might be used for
712 making a config object. This function does not check if the paths
713 actually exist.
715 :param appname: App name to be used as basis for default filenames.
717 :returns: List of default file paths.
718 """
719 if sys.platform == "win32":
720 # use pywin32 to fetch official defaults
721 try:
722 from win32com.shell import ( # pylint: disable=import-outside-toplevel
723 shell,
724 shellcon,
725 )
726 except ImportError:
727 return []
729 return [
730 # e.g. C:\..?? TODO: what is the user-specific path on win32?
731 os.path.join(
732 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_APPDATA),
733 appname,
734 f"{appname}.conf",
735 ),
736 os.path.join(
737 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_APPDATA),
738 f"{appname}.conf",
739 ),
740 # e.g. C:\ProgramData\wutta\wutta.conf
741 os.path.join(
742 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_COMMON_APPDATA),
743 appname,
744 f"{appname}.conf",
745 ),
746 os.path.join(
747 shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_COMMON_APPDATA),
748 f"{appname}.conf",
749 ),
750 ]
752 # default paths for *nix
753 return [
754 f"{sys.prefix}/app/{appname}.conf",
755 os.path.expanduser(f"~/.{appname}/{appname}.conf"),
756 os.path.expanduser(f"~/.{appname}.conf"),
757 f"/usr/local/etc/{appname}/{appname}.conf",
758 f"/usr/local/etc/{appname}.conf",
759 f"/etc/{appname}/{appname}.conf",
760 f"/etc/{appname}.conf",
761 ]
764def get_config_paths( # pylint: disable=too-many-arguments,too-many-positional-arguments
765 files=None,
766 plus_files=None,
767 appname="wutta",
768 env_files_name=None,
769 env_plus_files_name=None,
770 env=None,
771 default_files=None,
772 winsvc=None,
773):
774 """
775 This function determines which files should ultimately be provided
776 to the config constructor. It is normally called by
777 :func:`make_config()`.
779 In short, the files to be used are determined by typical priority:
781 * function params - ``files`` and ``plus_files``
782 * environment variables - e.g. ``WUTTA_CONFIG_FILES``
783 * app defaults - e.g. :func:`generic_default_files()`
785 The "main" and so-called "plus" config files are dealt with
786 separately, so that "defaults" can be used for the main files, and
787 any "plus" files are then added to the result.
789 In the end it combines everything it finds into a single list.
790 Note that it does not necessarily check to see if these files
791 exist.
793 :param files: Explicit set of "main" config files. If not
794 specified, environment variables and/or default lookup will be
795 done to get the "main" file set. Specify an empty list to
796 force an empty main file set.
798 :param plus_files: Explicit set of "plus" config files. Same
799 rules apply here as for the ``files`` param.
801 :param appname: The "app name" to use as basis for other things -
802 namely, constructing the default config file paths etc. For
803 instance the default ``appname`` value is ``'wutta'`` which
804 leads to default env vars like ``WUTTA_CONFIG_FILES``.
806 :param env_files_name: Name of the environment variable to read,
807 if ``files`` is not specified. The default is
808 ``WUTTA_CONFIG_FILES`` unless you override ``appname``.
810 :param env_plus_files_name: Name of the environment variable to
811 read, if ``plus_files`` is not specified. The default is
812 ``WUTTA_CONFIG_PLUS_FILES`` unless you override ``appname``.
814 :param env: Optional environment dict; if not specified
815 ``os.environ`` is used.
817 :param default_files: Optional lookup for "default" file paths.
819 This is only used a) for the "main" config file lookup (but not
820 "plus" files), and b) if neither ``files`` nor the environment
821 variables yielded anything.
823 If not specified, :func:`generic_default_files()` will be used
824 for the lookup.
826 You may specify a single file path as string, or a list of file
827 paths, or a callable which returns either of those things. For
828 example any of these could be used::
830 mydefaults = '/tmp/something.conf'
832 mydefaults = [
833 '/tmp/something.conf',
834 '/tmp/else.conf',
835 ]
837 def mydefaults(appname):
838 return [
839 f"/tmp/{appname}.conf",
840 f"/tmp/{appname}.ini",
841 ]
843 files = get_config_paths(default_files=mydefaults)
845 :param winsvc: Optional internal name of the Windows service for
846 which the config object is being made.
848 This is only needed for true Windows services running via
849 "Python for Windows Extensions" - which probably only includes
850 the Rattail File Monitor service.
852 In this context there is no way to tell the app which config
853 files to read on startup, so it can only look for "default"
854 files. But by passing a ``winsvc`` name to this function, it
855 will first load the default config file, then read a particular
856 value to determine the "real" config file(s) it should use.
858 So for example on Windows you might have a config file at
859 ``C:\\ProgramData\\rattail\\rattail.conf`` with contents:
861 .. code-block:: ini
863 [rattail.config]
864 winsvc.RattailFileMonitor = C:\\ProgramData\\rattail\\filemon.conf
866 And then ``C:\\ProgramData\\rattail\\filemon.conf`` would have
867 the actual config for the filemon service.
869 When the service starts it calls::
871 make_config(winsvc='RattailFileMonitor')
873 which first reads the ``rattail.conf`` file (since that is the
874 only sensible default), but then per config it knows to swap
875 that out for ``filemon.conf`` at startup. This is because it
876 finds a config value matching the requested service name. The
877 end result is as if it called this instead::
879 make_config(files=[r'C:\\ProgramData\\rattail\\filemon.conf'])
881 :returns: List of file paths.
882 """
883 if env is None:
884 env = os.environ
886 # first identify any "primary" config files
887 if files is None:
888 files = _get_primary_config_files(appname, env, env_files_name, default_files)
889 elif isinstance(files, str):
890 files = [files]
891 else:
892 files = list(files)
894 # then identify any "plus" (config tweak) files
895 if plus_files is None:
896 if not env_plus_files_name:
897 env_plus_files_name = f"{appname.upper()}_CONFIG_PLUS_FILES"
899 plus_files = env.get(env_plus_files_name)
900 if plus_files is not None:
901 plus_files = plus_files.split(os.pathsep)
903 else:
904 plus_files = []
906 elif isinstance(plus_files, str):
907 plus_files = [plus_files]
908 else:
909 plus_files = list(plus_files)
911 # combine all files
912 files.extend(plus_files)
914 # when running as a proper windows service, must first read
915 # "default" file(s) and then consult config to see which file
916 # should "really" be used. because there isn't a way to specify
917 # which config file as part of the actual service definition in
918 # windows, so the service name is used for magic lookup here.
919 if winsvc:
920 files = _get_winsvc_config_files(appname, winsvc, files)
922 return files
925def _get_primary_config_files(appname, env, env_files_name, default_files):
926 if not env_files_name:
927 env_files_name = f"{appname.upper()}_CONFIG_FILES"
929 files = env.get(env_files_name)
930 if files is not None:
931 return files.split(os.pathsep)
933 if default_files:
934 if callable(default_files):
935 files = default_files(appname) or []
936 elif isinstance(default_files, str):
937 files = [default_files]
938 else:
939 files = list(default_files)
940 return [path for path in files if os.path.exists(path)]
942 files = []
943 for path in generic_default_files(appname):
944 if os.path.exists(path):
945 files.append(path)
946 return files
949def _get_winsvc_config_files(appname, winsvc, files):
950 config = configparser.ConfigParser()
951 config.read(files)
952 section = f"{appname}.config"
953 if config.has_section(section):
954 option = f"winsvc.{winsvc}"
955 if config.has_option(section, option):
956 # replace file paths with whatever config value says
957 files = parse_list(config.get(section, option))
958 return files
961def make_config( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
962 files=None,
963 plus_files=None,
964 appname="wutta",
965 env_files_name=None,
966 env_plus_files_name=None,
967 env=None,
968 default_files=None,
969 winsvc=None,
970 usedb=None,
971 preferdb=None,
972 factory=None,
973 extend=True,
974 extension_entry_points=None,
975 **kwargs,
976):
977 """
978 Make a new config (usually :class:`WuttaConfig`) object,
979 initialized per the given parameters and (usually) further
980 modified by all registered config extensions.
982 This function really does 3 things:
984 * determine the set of config files to use
985 * pass those files to config factory
986 * apply extensions to the resulting config object
988 Some params are described in :func:`get_config_paths()` since they
989 are passed as-is to that function for the first step.
991 :param appname: The :term:`app name` to use as basis for other
992 things - namely, it affects how config files are located. This
993 name is also passed to the config factory at which point it
994 becomes :attr:`~wuttjamaican.conf.WuttaConfig.appname`.
996 :param usedb: Passed to the config factory; becomes
997 :attr:`~wuttjamaican.conf.WuttaConfig.usedb`.
999 :param preferdb: Passed to the config factory; becomes
1000 :attr:`~wuttjamaican.conf.WuttaConfig.preferdb`.
1002 :param factory: Optional factory to use when making the object.
1003 Default factory is :class:`WuttaConfig`.
1005 :param extend: Whether to "auto-extend" the config with all
1006 registered extensions.
1008 As a general rule, ``make_config()`` should only be called
1009 once, upon app startup. This is because some of the config
1010 extensions may do things which should only happen one time.
1011 However if ``extend=False`` is specified, then no extensions
1012 are invoked, so this may be done multiple times.
1014 (Why anyone would need this, is another question..maybe only
1015 useful for tests.)
1017 :param extension_entry_points: Name of the ``setuptools`` entry
1018 points section, used to identify registered config extensions.
1019 The default is ``wutta.config.extensions`` unless you override
1020 ``appname``.
1022 :returns: The new config object.
1023 """
1025 # nb. always show deprecation warnings when making config
1026 with warnings.catch_warnings():
1027 warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt")
1029 # collect file paths
1030 files = get_config_paths(
1031 files=files,
1032 plus_files=plus_files,
1033 appname=appname,
1034 env_files_name=env_files_name,
1035 env_plus_files_name=env_plus_files_name,
1036 env=env,
1037 default_files=default_files,
1038 winsvc=winsvc,
1039 )
1041 # make config object
1042 if not factory:
1043 factory = WuttaConfig
1044 config = factory(
1045 files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs
1046 )
1048 # maybe extend config object
1049 if extend:
1050 if not extension_entry_points:
1051 # nb. must not use appname here, entry points must be
1052 # consistent regardless of appname
1053 extension_entry_points = "wutta.config.extensions"
1055 # apply all registered extensions
1056 # TODO: maybe let config disable some extensions?
1057 extensions = load_entry_points(extension_entry_points)
1058 extensions = [ext() for ext in extensions.values()]
1059 for extension in extensions:
1060 log.debug("applying config extension: %s", extension.key)
1061 extension.configure(config)
1063 # let extensions run startup hooks if needed
1064 for extension in extensions:
1065 extension.startup(config)
1067 # maybe show deprecation warnings from now on
1068 if config.get_bool(
1069 f"{config.appname}.show_deprecation_warnings", usedb=False, default=True
1070 ):
1071 warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt")
1073 return config
1076class WuttaConfigProfile:
1077 """
1078 Base class to represent a configured "profile" in the context of
1079 some service etc.
1081 :param config: App :term:`config object`.
1083 :param key: Config key for the profile.
1085 Generally each subclass will represent a certain type of config
1086 profile, and each instance will represent a single profile
1087 (identified by the ``key``).
1088 """
1090 def __init__(self, config, key):
1091 self.config = config
1092 self.app = self.config.get_app()
1093 self.key = key
1094 self.load()
1096 @property
1097 def section(self):
1098 """
1099 The primary config section under which profiles may be
1100 defined.
1102 There is no default; each subclass must declare it.
1104 This corresponds to the typical INI file section, for instance
1105 a section of ``wutta.telemetry`` assumes file contents like:
1107 .. code-block:: ini
1109 [wutta.telemetry]
1110 default.submit_url = /nodes/telemetry
1111 special.submit_url = /nodes/telemetry-special
1112 """
1113 raise NotImplementedError
1115 def load(self):
1116 """
1117 Read all relevant settings from config, and assign attributes
1118 on the profile instance accordingly.
1120 There is no default logic but subclass will generally override.
1122 While a caller can use :meth:`get_str()` to obtain arbitrary
1123 config values dynamically, it is often useful for the profile
1124 to pre-load some config values. This allows "smarter"
1125 interpretation of config values in some cases, and at least
1126 ensures common/shared logic.
1128 There is no constraint or other guidance in terms of which
1129 profile attributes might be set by this method. Subclass
1130 should document if necessary.
1131 """
1133 def get_str(self, option, **kwargs):
1134 """
1135 Get a string value for the profile, from config.
1137 :param option: Name of config option for which to return value.
1139 This just calls :meth:`~WuttaConfig.get()` on the config
1140 object, but for a particular setting name which it composes
1141 dynamically.
1143 Assuming a config file like:
1145 .. code-block:: ini
1147 [wutta.telemetry]
1148 default.submit_url = /nodes/telemetry
1150 Then a ``default`` profile under the ``wutta.telemetry``
1151 section would effectively have a ``submit_url`` option::
1153 class TelemetryProfile(WuttaConfigProfile):
1154 section = "wutta.telemetry"
1156 profile = TelemetryProfile("default")
1157 url = profile.get_str("submit_url")
1158 """
1159 return self.config.get(f"{self.section}.{self.key}.{option}", **kwargs)