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.settings.poe import Hook, HooksEngine 26 27__all__ = [ 28 'DEFAULT_LOGGING_CONFIG', 29 'DEFAULT_PATHS', 30 'DestSpec', 31 'ObjectSpec', 32 'PREFIX', 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': { 70 'class': 'rich.logging.RichHandler', 71 'formatter': 'simple', 72 'rich_tracebacks': True, 73 }, 74 }, 75 'loggers': {}, 76 'root': { 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 `config_ninja.settings.poe.HooksEngine` is imported and loaded if the `poethepoet` extra is installed. 108 """ 109 try: 110 from config_ninja.settings.poe import HooksEngine, exceptions 111 except ImportError: 112 return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX)) 113 114 try: 115 engine = HooksEngine.load_file(path) 116 except exceptions.PoeException: 117 # this is expected if the file does not define any hooks 118 logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True) 119 engine = None 120 return Config(engine=engine, settings=pyspry.Settings.load(path, PREFIX)) 121 122 123def resolve_path() -> Path: 124 """Return the first path in `DEFAULT_PATHS` that exists.""" 125 for path in DEFAULT_PATHS: 126 if path.is_file(): 127 return path 128 129 raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS) 130 131 132@dataclasses.dataclass 133class DestSpec: 134 """Container for the destination spec parsed from `config-ninja`_'s own configuration file. 135 136 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 137 """ 138 139 format: FormatT | jinja2.Template 140 """Specify the format of the configuration file to write.""" 141 142 path: Path 143 """Write the configuration file to this path.""" 144 145 def __str__(self) -> str: 146 """Represent the destination spec as a string.""" 147 if self.is_template: 148 assert isinstance(self.format, jinja2.Template) # noqa: S101 # 👈 for static analysis 149 fmt = f'(template: {self.format.name})' 150 else: 151 fmt = f'(format: {self.format})' 152 153 return f'{fmt} -> {self.path}' 154 155 @classmethod 156 def from_primitives(cls, data: Dest) -> DestSpec: 157 """Create a `DestSpec` instance from a dictionary of primitive types.""" 158 path = Path(data['path']) 159 if data['format'] in DUMPERS: 160 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 161 return DestSpec(format=fmt, path=path) 162 163 template_path = Path(data['format']) 164 165 loader = jinja2.FileSystemLoader(template_path.parent) 166 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 167 168 return DestSpec(path=path, format=env.get_template(template_path.name)) 169 170 @property 171 def is_template(self) -> bool: 172 """Whether the destination uses a Jinja2 template.""" 173 return isinstance(self.format, jinja2.Template) 174 175 176@dataclasses.dataclass 177class SourceSpec: 178 """The data source of a `config-ninja`_ object. 179 180 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 181 """ 182 183 backend: Backend 184 """Read configuration data from this backend (see `config_ninja.contrib` for supported backends).""" 185 186 format: FormatT = 'raw' 187 """Decode the source data from this format.""" 188 189 @classmethod 190 def from_primitives(cls, data: Source) -> SourceSpec: 191 """Create a `SourceSpec` instance from a dictionary of primitive types. 192 193 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 194 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 195 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 196 """ 197 backend_class = get_backend(data['backend']) 198 fmt = data.get('format', 'raw') 199 if new := data.get('new'): 200 backend = backend_class.new(**new['kwargs']) 201 else: 202 backend = backend_class(**data['init']['kwargs']) 203 204 return SourceSpec(backend=backend, format=fmt) 205 206 207@dataclasses.dataclass 208class ObjectSpec: 209 """Container for each object parsed from `config-ninja`_'s own configuration file. 210 211 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 212 """ 213 214 dest: DestSpec 215 """Destination metadata for the object's output file.""" 216 217 hooks: tuple[Hook, ...] 218 """Zero or more `poethepoet` tasks to execute as callback hooks.""" 219 220 source: SourceSpec 221 """Configuration for the object's `config_ninja.backend.Backend` data source.""" 222 223 @staticmethod 224 def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]: 225 hook_names: list[str] = data.get('hooks', []) 226 if hook_names and engine is None: 227 raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}") 228 229 return tuple(engine.get_hook(hook_name) for hook_name in hook_names) # type: ignore[union-attr] 230 231 @classmethod 232 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 233 """Create an `ObjectSpec` instance from a dictionary of primitive types.""" 234 return ObjectSpec( 235 dest=DestSpec.from_primitives(data['dest']), 236 hooks=cls._load_hooks(data, engine), 237 source=SourceSpec.from_primitives(data['source']), 238 ) 239 240 241logger.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
133@dataclasses.dataclass 134class DestSpec: 135 """Container for the destination spec parsed from `config-ninja`_'s own configuration file. 136 137 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 138 """ 139 140 format: FormatT | jinja2.Template 141 """Specify the format of the configuration file to write.""" 142 143 path: Path 144 """Write the configuration file to this path.""" 145 146 def __str__(self) -> str: 147 """Represent the destination spec as a string.""" 148 if self.is_template: 149 assert isinstance(self.format, jinja2.Template) # noqa: S101 # 👈 for static analysis 150 fmt = f'(template: {self.format.name})' 151 else: 152 fmt = f'(format: {self.format})' 153 154 return f'{fmt} -> {self.path}' 155 156 @classmethod 157 def from_primitives(cls, data: Dest) -> DestSpec: 158 """Create a `DestSpec` instance from a dictionary of primitive types.""" 159 path = Path(data['path']) 160 if data['format'] in DUMPERS: 161 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 162 return DestSpec(format=fmt, path=path) 163 164 template_path = Path(data['format']) 165 166 loader = jinja2.FileSystemLoader(template_path.parent) 167 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 168 169 return DestSpec(path=path, format=env.get_template(template_path.name)) 170 171 @property 172 def is_template(self) -> bool: 173 """Whether the destination uses a Jinja2 template.""" 174 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.
156 @classmethod 157 def from_primitives(cls, data: Dest) -> DestSpec: 158 """Create a `DestSpec` instance from a dictionary of primitive types.""" 159 path = Path(data['path']) 160 if data['format'] in DUMPERS: 161 fmt: FormatT = data['format'] # type: ignore[assignment,unused-ignore] 162 return DestSpec(format=fmt, path=path) 163 164 template_path = Path(data['format']) 165 166 loader = jinja2.FileSystemLoader(template_path.parent) 167 env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader) 168 169 return DestSpec(path=path, format=env.get_template(template_path.name))
Create a DestSpec instance from a dictionary of primitive types.
208@dataclasses.dataclass 209class ObjectSpec: 210 """Container for each object parsed from `config-ninja`_'s own configuration file. 211 212 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 213 """ 214 215 dest: DestSpec 216 """Destination metadata for the object's output file.""" 217 218 hooks: tuple[Hook, ...] 219 """Zero or more `poethepoet` tasks to execute as callback hooks.""" 220 221 source: SourceSpec 222 """Configuration for the object's `config_ninja.backend.Backend` data source.""" 223 224 @staticmethod 225 def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]: 226 hook_names: list[str] = data.get('hooks', []) 227 if hook_names and engine is None: 228 raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}") 229 230 return tuple(engine.get_hook(hook_name) for hook_name in hook_names) # type: ignore[union-attr] 231 232 @classmethod 233 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 234 """Create an `ObjectSpec` instance from a dictionary of primitive types.""" 235 return ObjectSpec( 236 dest=DestSpec.from_primitives(data['dest']), 237 hooks=cls._load_hooks(data, engine), 238 source=SourceSpec.from_primitives(data['source']), 239 )
Container for each object parsed from config-ninja's own configuration file.
Zero or more poethepoet tasks to execute as callback hooks.
232 @classmethod 233 def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec: 234 """Create an `ObjectSpec` instance from a dictionary of primitive types.""" 235 return ObjectSpec( 236 dest=DestSpec.from_primitives(data['dest']), 237 hooks=cls._load_hooks(data, engine), 238 source=SourceSpec.from_primitives(data['source']), 239 )
Create an ObjectSpec instance from a dictionary of primitive types.
Each of config-ninja's settings must be prefixed with this string.
177@dataclasses.dataclass 178class SourceSpec: 179 """The data source of a `config-ninja`_ object. 180 181 .. _config-ninja: https://config-ninja.readthedocs.io/home.html 182 """ 183 184 backend: Backend 185 """Read configuration data from this backend (see `config_ninja.contrib` for supported backends).""" 186 187 format: FormatT = 'raw' 188 """Decode the source data from this format.""" 189 190 @classmethod 191 def from_primitives(cls, data: Source) -> SourceSpec: 192 """Create a `SourceSpec` instance from a dictionary of primitive types. 193 194 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 195 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 196 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 197 """ 198 backend_class = get_backend(data['backend']) 199 fmt = data.get('format', 'raw') 200 if new := data.get('new'): 201 backend = backend_class.new(**new['kwargs']) 202 else: 203 backend = backend_class(**data['init']['kwargs']) 204 205 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.
190 @classmethod 191 def from_primitives(cls, data: Source) -> SourceSpec: 192 """Create a `SourceSpec` instance from a dictionary of primitive types. 193 194 If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is 195 invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing 196 arguments to the `config_ninja.backend.Backend`'s `__init__()` method. 197 """ 198 backend_class = get_backend(data['backend']) 199 fmt = data.get('format', 'raw') 200 if new := data.get('new'): 201 backend = backend_class.new(**new['kwargs']) 202 else: 203 backend = backend_class(**data['init']['kwargs']) 204 205 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 `config_ninja.settings.poe.HooksEngine` is imported and loaded if the `poethepoet` extra is installed. 109 """ 110 try: 111 from config_ninja.settings.poe import HooksEngine, exceptions 112 except ImportError: 113 return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX)) 114 115 try: 116 engine = HooksEngine.load_file(path) 117 except exceptions.PoeException: 118 # this is expected if the file does not define any hooks 119 logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True) 120 engine = None 121 return Config(engine=engine, settings=pyspry.Settings.load(path, PREFIX))
Load the settings from the given path.
config_ninja.settings.poe.HooksEngine is imported and loaded if the poethepoet extra is installed.
124def resolve_path() -> Path: 125 """Return the first path in `DEFAULT_PATHS` that exists.""" 126 for path in DEFAULT_PATHS: 127 if path.is_file(): 128 return path 129 130 raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS)
Return the first path in DEFAULT_PATHS that exists.