config_ninja.settings
Read and deserialize configuration for the config-ninja agent.
Schema
See config_ninja.settings.schema
for the schema of the config-ninja settings file.
1"""Read and deserialize configuration for the `config-ninja`_ agent. 2 3## Schema 4 5See `config_ninja.settings.schema` for the schema of the `config-ninja`_ settings file. 6 7.. _config-ninja: https://config-ninja.readthedocs.io/home.html 8""" 9 10from __future__ import annotations 11 12import dataclasses 13import logging 14import typing 15from pathlib import Path 16 17import jinja2 18import pyspry 19 20from config_ninja.backend import DUMPERS, Backend, FormatT 21from config_ninja.contrib import get_backend 22from config_ninja.settings.schema import ConfigNinjaObject, Dest, DictConfigDefault, Source 23 24if typing.TYPE_CHECKING: # pragma: no cover 25 from config_ninja.hooks import Hook, HooksEngine 26 27__all__ = [ 28 'DEFAULT_LOGGING_CONFIG', 29 'DEFAULT_PATHS', 30 'PREFIX', 31 'DestSpec', 32 'ObjectSpec', 33 'SourceSpec', 34 'load', 35 'resolve_path', 36] 37 38logger = logging.getLogger(__name__) 39 40DEFAULT_PATHS = [ 41 Path.cwd() / 'config-ninja-settings.yaml', 42 Path.home() / 'config-ninja-settings.yaml', 43 Path('/etc/config-ninja/settings.yaml'), 44] 45"""Check each of these locations for `config-ninja`_'s settings file. 46 47The following locations are checked (ordered by priority): 48 491. `./config-ninja-settings.yaml` 502. `~/config-ninja-settings.yaml` 513. `/etc/config-ninja/settings.yaml` 52 53.. _config-ninja: https://config-ninja.readthedocs.io/home.html 54""" 55 56 57DEFAULT_LOGGING_CONFIG: DictConfigDefault = { 58 'version': 1, 59 'formatters': { 60 'simple': { 61 'datefmt': logging.Formatter.default_time_format, 62 'format': '%(message)s', 63 'style': '%', 64 'validate': False, 65 }, 66 }, 67 'filters': {}, 68 'handlers': { 69 'rich': { # type: ignore[typeddict-item,unused-ignore] # needed for Python <3.11 70 'class': 'rich.logging.RichHandler', 71 'formatter': 'simple', 72 'rich_tracebacks': True, 73 }, 74 }, 75 'loggers': {}, 76 'root': { # type: ignore[typeddict-item,unused-ignore] # needed for Python <3.11 77 'handlers': ['rich'], 78 'level': logging.INFO, 79 'propagate': False, 80 }, 81 'disable_existing_loggers': True, 82 'incremental': False, 83} 84"""Default logging configuration passed to `logging.config.dictConfig()`.""" 85 86PREFIX = 'CONFIG_NINJA' 87"""Each of `config-ninja`_'s settings must be prefixed with this string. 88 89.. _config-ninja: https://config-ninja.readthedocs.io/home.html 90""" 91 92 93@dataclasses.dataclass 94class Config: 95 """Wrap the `pyspry.Settings` object with additional configuration for the `HooksEngine`.""" 96 97 settings: pyspry.Settings 98 """The settings for the `config-ninja` agent.""" 99 100 engine: HooksEngine | None = None 101 """If `poethepoet` is installed and configured, use this engine for callback hooks.""" 102 103 104def load(path: Path) -> Config: 105 """Load the settings from the given path. 106 107 >>> conf = load('config-ninja-settings.yaml') 108 >>> conf.settings.__class__, conf.engine.__class__ 109 (<class 'pyspry.base.Settings'>, <class 'config_ninja.hooks.HooksEngine'>) 110 111 112 `config_ninja.hooks.HooksEngine` is imported and loaded if the `poethepoet` extra is installed. When not 113 installed, the `ImportError` is handled, and `engine=None` is returned in place of 114 `config_ninja.hooks.HooksEngine`. 115 116 >>> seed_import_error() 117 >>> load('examples/hooks.yaml').engine is None 118 True 119 120 """ 121 try: 122 from config_ninja.hooks import ( # noqa: PLC0415 # import is performed in function to avoid cycles 123 HooksEngine, 124 exceptions, 125 ) 126 except ImportError as exc: 127 logger.debug('%s: %s', exc.name, exc.msg) 128 return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX)) 129 130 try: 131 engine = HooksEngine.load_file(path, DEFAULT_PATHS) 132 except exceptions.PoeException: 133 # this is expected if the file does not define any hooks 134 logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True) 135 engine = None 136 return Config(engine=engine, settings=pyspry.Settings.load(path, PREFIX)) 137 138 139def resolve_path() -> Path: 140 """Return the first path in `DEFAULT_PATHS` that exists.""" 141 for path in DEFAULT_PATHS: 142 if path.is_file(): 143 return path 144 145 raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS) 146 147 148@dataclasses.dataclass 149class DestSpec: 150 """Container for the destination spec parsed from `config-ninja`_'s own configuration file. 151 152 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 153 """ 154 155 format: FormatT | jinja2.Template 156 """Specify the format of the configuration file to write.""" 157 158 path: Path 159 """Write the configuration file to this path.""" 160 161 def __str__(self) -> str: 162 """Represent the destination spec as a string.""" 163 if self.is_template: 164 assert isinstance(self.format, jinja2.Template) # noqa: S101 # 👈 for static analysis 165 fmt = f'(template: {self.format.name})' 166 else: 167 fmt = f'(format: {self.format})' 168 169 return f'{fmt} -> {self.path}' 170 171 @classmethod 172 def from_primitives(cls, data: Dest) -> DestSpec: 173 """Create a `DestSpec` instance from a dictionary of primitive types.""" 174 path = Path(data['path']) 175 if data['format'] in DUMPERS: 176 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 177 return DestSpec(format=fmt, path=path) 178 179 template_path = Path(data['format']) 180 181 loader = jinja2.FileSystemLoader(template_path.parent) 182 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 183 184 return DestSpec(path=path, format=env.get_template(template_path.name)) 185 186 @property 187 def is_template(self) -> bool: 188 """Whether the destination uses a Jinja2 template.""" 189 return isinstance(self.format, jinja2.Template) 190 191 192@dataclasses.dataclass 193class SourceSpec: 194 """The data source of a `config-ninja`_ object. 195 196 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 197 """ 198 199 backend: Backend 200 """Read configuration data from this backend (see `config_ninja.contrib` for supported backends).""" 201 202 format: FormatT = 'raw' 203 """Decode the source data from this format.""" 204 205 @classmethod 206 def from_primitives(cls, data: Source) -> SourceSpec: 207 """Create a `SourceSpec` instance from a dictionary of primitive types. 208 209 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 210 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 211 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 212 """ 213 backend_class = get_backend(data['backend']) 214 fmt = data.get('format', 'raw') 215 if new := data.get('new'): 216 backend = backend_class.new(**new['kwargs']) 217 else: 218 backend = backend_class(**data['init']['kwargs']) 219 220 return SourceSpec(backend=backend, format=fmt) 221 222 223@dataclasses.dataclass 224class ObjectSpec: 225 """Container for each object parsed from `config-ninja`_'s own configuration file. 226 227 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 228 """ 229 230 dest: DestSpec 231 """Destination metadata for the object's output file.""" 232 233 hooks: tuple[Hook, ...] 234 """Zero or more `poethepoet` tasks to execute as callback hooks.""" 235 236 source: SourceSpec 237 """Configuration for the object's `config_ninja.backend.Backend` data source.""" 238 239 @staticmethod 240 def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]: 241 hook_names: list[str] = data.get('hooks', []) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] 242 if hook_names and engine is None: 243 raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}") 244 245 return tuple(engine.get_hook(hook_name) for hook_name in hook_names) # type: ignore[union-attr] 246 247 @classmethod 248 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 249 """Create an `ObjectSpec` instance from a dictionary of primitive types. 250 251 A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not 252 provided: 253 254 >>> data = { 255 ... 'dest': { 256 ... 'path': '/dev/null', 257 ... 'format': 'yaml' 258 ... }, 259 ... 'source': { 260 ... 'backend': 'local', 261 ... 'init': { 262 ... 'kwargs': { 263 ... 'path': 'example.yaml' 264 ... } 265 ... } 266 ... }, 267 ... 'hooks': ['foo'] 268 ... } 269 270 >>> ObjectSpec.from_primitives(data, None) 271 Traceback (most recent call last): 272 ... 273 ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ... 274 """ 275 return ObjectSpec( 276 dest=DestSpec.from_primitives(data['dest']), 277 hooks=cls._load_hooks(data, engine), 278 source=SourceSpec.from_primitives(data['source']), 279 ) 280 281 282logger.debug('successfully imported %s', __name__)
Default logging configuration passed to logging.config.dictConfig()
.
Check each of these locations for config-ninja's settings file.
The following locations are checked (ordered by priority):
./config-ninja-settings.yaml
~/config-ninja-settings.yaml
/etc/config-ninja/settings.yaml
Each of config-ninja's settings must be prefixed with this string.
149@dataclasses.dataclass 150class DestSpec: 151 """Container for the destination spec parsed from `config-ninja`_'s own configuration file. 152 153 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 154 """ 155 156 format: FormatT | jinja2.Template 157 """Specify the format of the configuration file to write.""" 158 159 path: Path 160 """Write the configuration file to this path.""" 161 162 def __str__(self) -> str: 163 """Represent the destination spec as a string.""" 164 if self.is_template: 165 assert isinstance(self.format, jinja2.Template) # noqa: S101 # 👈 for static analysis 166 fmt = f'(template: {self.format.name})' 167 else: 168 fmt = f'(format: {self.format})' 169 170 return f'{fmt} -> {self.path}' 171 172 @classmethod 173 def from_primitives(cls, data: Dest) -> DestSpec: 174 """Create a `DestSpec` instance from a dictionary of primitive types.""" 175 path = Path(data['path']) 176 if data['format'] in DUMPERS: 177 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 178 return DestSpec(format=fmt, path=path) 179 180 template_path = Path(data['format']) 181 182 loader = jinja2.FileSystemLoader(template_path.parent) 183 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 184 185 return DestSpec(path=path, format=env.get_template(template_path.name)) 186 187 @property 188 def is_template(self) -> bool: 189 """Whether the destination uses a Jinja2 template.""" 190 return isinstance(self.format, jinja2.Template)
Container for the destination spec parsed from config-ninja's own configuration file.
Specify the format of the configuration file to write.
172 @classmethod 173 def from_primitives(cls, data: Dest) -> DestSpec: 174 """Create a `DestSpec` instance from a dictionary of primitive types.""" 175 path = Path(data['path']) 176 if data['format'] in DUMPERS: 177 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 178 return DestSpec(format=fmt, path=path) 179 180 template_path = Path(data['format']) 181 182 loader = jinja2.FileSystemLoader(template_path.parent) 183 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 184 185 return DestSpec(path=path, format=env.get_template(template_path.name))
Create a DestSpec
instance from a dictionary of primitive types.
224@dataclasses.dataclass 225class ObjectSpec: 226 """Container for each object parsed from `config-ninja`_'s own configuration file. 227 228 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 229 """ 230 231 dest: DestSpec 232 """Destination metadata for the object's output file.""" 233 234 hooks: tuple[Hook, ...] 235 """Zero or more `poethepoet` tasks to execute as callback hooks.""" 236 237 source: SourceSpec 238 """Configuration for the object's `config_ninja.backend.Backend` data source.""" 239 240 @staticmethod 241 def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]: 242 hook_names: list[str] = data.get('hooks', []) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] 243 if hook_names and engine is None: 244 raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}") 245 246 return tuple(engine.get_hook(hook_name) for hook_name in hook_names) # type: ignore[union-attr] 247 248 @classmethod 249 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 250 """Create an `ObjectSpec` instance from a dictionary of primitive types. 251 252 A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not 253 provided: 254 255 >>> data = { 256 ... 'dest': { 257 ... 'path': '/dev/null', 258 ... 'format': 'yaml' 259 ... }, 260 ... 'source': { 261 ... 'backend': 'local', 262 ... 'init': { 263 ... 'kwargs': { 264 ... 'path': 'example.yaml' 265 ... } 266 ... } 267 ... }, 268 ... 'hooks': ['foo'] 269 ... } 270 271 >>> ObjectSpec.from_primitives(data, None) 272 Traceback (most recent call last): 273 ... 274 ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ... 275 """ 276 return ObjectSpec( 277 dest=DestSpec.from_primitives(data['dest']), 278 hooks=cls._load_hooks(data, engine), 279 source=SourceSpec.from_primitives(data['source']), 280 )
Container for each object parsed from config-ninja's own configuration file.
Zero or more poethepoet
tasks to execute as callback hooks.
248 @classmethod 249 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 250 """Create an `ObjectSpec` instance from a dictionary of primitive types. 251 252 A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not 253 provided: 254 255 >>> data = { 256 ... 'dest': { 257 ... 'path': '/dev/null', 258 ... 'format': 'yaml' 259 ... }, 260 ... 'source': { 261 ... 'backend': 'local', 262 ... 'init': { 263 ... 'kwargs': { 264 ... 'path': 'example.yaml' 265 ... } 266 ... } 267 ... }, 268 ... 'hooks': ['foo'] 269 ... } 270 271 >>> ObjectSpec.from_primitives(data, None) 272 Traceback (most recent call last): 273 ... 274 ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ... 275 """ 276 return ObjectSpec( 277 dest=DestSpec.from_primitives(data['dest']), 278 hooks=cls._load_hooks(data, engine), 279 source=SourceSpec.from_primitives(data['source']), 280 )
Create an ObjectSpec
instance from a dictionary of primitive types.
A ValueError
is raised when hooks are referenced by the ConfigNinjaObject
but the HooksEngine
is not
provided:
>>> data = {
... 'dest': {
... 'path': '/dev/null',
... 'format': 'yaml'
... },
... 'source': {
... 'backend': 'local',
... 'init': {
... 'kwargs': {
... 'path': 'example.yaml'
... }
... }
... },
... 'hooks': ['foo']
... }
>>> ObjectSpec.from_primitives(data, None)
Traceback (most recent call last):
...
ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ...
193@dataclasses.dataclass 194class SourceSpec: 195 """The data source of a `config-ninja`_ object. 196 197 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 198 """ 199 200 backend: Backend 201 """Read configuration data from this backend (see `config_ninja.contrib` for supported backends).""" 202 203 format: FormatT = 'raw' 204 """Decode the source data from this format.""" 205 206 @classmethod 207 def from_primitives(cls, data: Source) -> SourceSpec: 208 """Create a `SourceSpec` instance from a dictionary of primitive types. 209 210 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 211 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 212 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 213 """ 214 backend_class = get_backend(data['backend']) 215 fmt = data.get('format', 'raw') 216 if new := data.get('new'): 217 backend = backend_class.new(**new['kwargs']) 218 else: 219 backend = backend_class(**data['init']['kwargs']) 220 221 return SourceSpec(backend=backend, format=fmt)
The data source of a config-ninja object.
Read configuration data from this backend (see config_ninja.contrib
for supported backends).
Decode the source data from this format.
206 @classmethod 207 def from_primitives(cls, data: Source) -> SourceSpec: 208 """Create a `SourceSpec` instance from a dictionary of primitive types. 209 210 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 211 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 212 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 213 """ 214 backend_class = get_backend(data['backend']) 215 fmt = data.get('format', 'raw') 216 if new := data.get('new'): 217 backend = backend_class.new(**new['kwargs']) 218 else: 219 backend = backend_class(**data['init']['kwargs']) 220 221 return SourceSpec(backend=backend, format=fmt)
Create a SourceSpec
instance from a dictionary of primitive types.
If the given Source
has a Source.new
key, the appropriate config_ninja.backend.Backend.new()
method is
invoked to create SourceSpec.backend
. Otherwise, the Source
must have a Source.init
key for passing
arguments to the config_ninja.backend.Backend
's __init__()
method.
105def load(path: Path) -> Config: 106 """Load the settings from the given path. 107 108 >>> conf = load('config-ninja-settings.yaml') 109 >>> conf.settings.__class__, conf.engine.__class__ 110 (<class 'pyspry.base.Settings'>, <class 'config_ninja.hooks.HooksEngine'>) 111 112 113 `config_ninja.hooks.HooksEngine` is imported and loaded if the `poethepoet` extra is installed. When not 114 installed, the `ImportError` is handled, and `engine=None` is returned in place of 115 `config_ninja.hooks.HooksEngine`. 116 117 >>> seed_import_error() 118 >>> load('examples/hooks.yaml').engine is None 119 True 120 121 """ 122 try: 123 from config_ninja.hooks import ( # noqa: PLC0415 # import is performed in function to avoid cycles 124 HooksEngine, 125 exceptions, 126 ) 127 except ImportError as exc: 128 logger.debug('%s: %s', exc.name, exc.msg) 129 return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX)) 130 131 try: 132 engine = HooksEngine.load_file(path, DEFAULT_PATHS) 133 except exceptions.PoeException: 134 # this is expected if the file does not define any hooks 135 logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True) 136 engine = None 137 return Config(engine=engine, settings=pyspry.Settings.load(path, PREFIX))
Load the settings from the given path.
>>> conf = load('config-ninja-settings.yaml')
>>> conf.settings.__class__, conf.engine.__class__
(<class 'pyspry.base.Settings'>, <class 'config_ninja.hooks.HooksEngine'>)
config_ninja.hooks.HooksEngine
is imported and loaded if the poethepoet
extra is installed. When not
installed, the ImportError
is handled, and engine=None
is returned in place of
config_ninja.hooks.HooksEngine
.
>>> seed_import_error()
>>> load('examples/hooks.yaml').engine is None
True
140def resolve_path() -> Path: 141 """Return the first path in `DEFAULT_PATHS` that exists.""" 142 for path in DEFAULT_PATHS: 143 if path.is_file(): 144 return path 145 146 raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS)
Return the first path in DEFAULT_PATHS
that exists.