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 HooksEngine, exceptions
123    except ImportError as exc:
124        logger.debug('%s: %s', exc.name, exc.msg)
125        return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX))
126
127    try:
128        engine = HooksEngine.load_file(path, DEFAULT_PATHS)
129    except exceptions.PoeException:
130        # this is expected if the file does not define any hooks
131        logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True)
132        engine = None
133    return Config(engine=engine, settings=pyspry.Settings.load(path, PREFIX))
134
135
136def resolve_path() -> Path:
137    """Return the first path in `DEFAULT_PATHS` that exists."""
138    for path in DEFAULT_PATHS:
139        if path.is_file():
140            return path
141
142    raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS)
143
144
145@dataclasses.dataclass
146class DestSpec:
147    """Container for the destination spec parsed from `config-ninja`_'s own configuration file.
148
149    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
150    """
151
152    format: FormatT | jinja2.Template
153    """Specify the format of the configuration file to write."""
154
155    path: Path
156    """Write the configuration file to this path."""
157
158    def __str__(self) -> str:
159        """Represent the destination spec as a string."""
160        if self.is_template:
161            assert isinstance(self.format, jinja2.Template)  # noqa: S101  # 👈 for static analysis
162            fmt = f'(template: {self.format.name})'
163        else:
164            fmt = f'(format: {self.format})'
165
166        return f'{fmt} -> {self.path}'
167
168    @classmethod
169    def from_primitives(cls, data: Dest) -> DestSpec:
170        """Create a `DestSpec` instance from a dictionary of primitive types."""
171        path = Path(data['path'])
172        if data['format'] in DUMPERS:
173            fmt: FormatT = data['format']  # type: ignore[assignment,unused-ignore]
174            return DestSpec(format=fmt, path=path)
175
176        template_path = Path(data['format'])
177
178        loader = jinja2.FileSystemLoader(template_path.parent)
179        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
180
181        return DestSpec(path=path, format=env.get_template(template_path.name))
182
183    @property
184    def is_template(self) -> bool:
185        """Whether the destination uses a Jinja2 template."""
186        return isinstance(self.format, jinja2.Template)
187
188
189@dataclasses.dataclass
190class SourceSpec:
191    """The data source of a `config-ninja`_ object.
192
193    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
194    """
195
196    backend: Backend
197    """Read configuration data from this backend (see `config_ninja.contrib` for supported backends)."""
198
199    format: FormatT = 'raw'
200    """Decode the source data from this format."""
201
202    @classmethod
203    def from_primitives(cls, data: Source) -> SourceSpec:
204        """Create a `SourceSpec` instance from a dictionary of primitive types.
205
206        If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is
207        invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing
208        arguments to the `config_ninja.backend.Backend`'s `__init__()` method.
209        """
210        backend_class = get_backend(data['backend'])
211        fmt = data.get('format', 'raw')
212        if new := data.get('new'):
213            backend = backend_class.new(**new['kwargs'])
214        else:
215            backend = backend_class(**data['init']['kwargs'])
216
217        return SourceSpec(backend=backend, format=fmt)
218
219
220@dataclasses.dataclass
221class ObjectSpec:
222    """Container for each object parsed from `config-ninja`_'s own configuration file.
223
224    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
225    """
226
227    dest: DestSpec
228    """Destination metadata for the object's output file."""
229
230    hooks: tuple[Hook, ...]
231    """Zero or more `poethepoet` tasks to execute as callback hooks."""
232
233    source: SourceSpec
234    """Configuration for the object's `config_ninja.backend.Backend` data source."""
235
236    @staticmethod
237    def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]:
238        hook_names: list[str] = data.get('hooks', [])  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
239        if hook_names and engine is None:
240            raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}")
241
242        return tuple(engine.get_hook(hook_name) for hook_name in hook_names)  # type: ignore[union-attr]
243
244    @classmethod
245    def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec:
246        """Create an `ObjectSpec` instance from a dictionary of primitive types.
247
248        A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not
249            provided:
250
251        >>> data = {
252        ...   'dest': {
253        ...     'path': '/dev/null',
254        ...     'format': 'yaml'
255        ...   },
256        ...   'source': {
257        ...     'backend': 'local',
258        ...     'init': {
259        ...       'kwargs': {
260        ...         'path': 'example.yaml'
261        ...       }
262        ...     }
263        ...   },
264        ...   'hooks': ['foo']
265        ... }
266
267        >>> ObjectSpec.from_primitives(data, None)
268        Traceback (most recent call last):
269        ...
270        ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ...
271        """
272        return ObjectSpec(
273            dest=DestSpec.from_primitives(data['dest']),
274            hooks=cls._load_hooks(data, engine),
275            source=SourceSpec.from_primitives(data['source']),
276        )
277
278
279logger.debug('successfully imported %s', __name__)
DEFAULT_LOGGING_CONFIG: config_ninja.settings.schema.DictConfigDefault = {'version': 1, 'formatters': {'simple': {'datefmt': '%Y-%m-%d %H:%M:%S', 'format': '%(message)s', 'style': '%', 'validate': False}}, 'filters': {}, 'handlers': {'rich': {'class': 'rich.logging.RichHandler', 'formatter': 'simple', 'rich_tracebacks': True}}, 'loggers': {}, 'root': {'handlers': ['rich'], 'level': 20, 'propagate': False}, 'disable_existing_loggers': True, 'incremental': False}

Default logging configuration passed to logging.config.dictConfig().

DEFAULT_PATHS = [PosixPath('/home/runner/work/config-ninja/config-ninja/config-ninja-settings.yaml'), PosixPath('/home/runner/config-ninja-settings.yaml'), PosixPath('/etc/config-ninja/settings.yaml')]

Check each of these locations for config-ninja's settings file.

The following locations are checked (ordered by priority):

  1. ./config-ninja-settings.yaml
  2. ~/config-ninja-settings.yaml
  3. /etc/config-ninja/settings.yaml
PREFIX = 'CONFIG_NINJA'

Each of config-ninja's settings must be prefixed with this string.

@dataclasses.dataclass
class DestSpec:
146@dataclasses.dataclass
147class DestSpec:
148    """Container for the destination spec parsed from `config-ninja`_'s own configuration file.
149
150    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
151    """
152
153    format: FormatT | jinja2.Template
154    """Specify the format of the configuration file to write."""
155
156    path: Path
157    """Write the configuration file to this path."""
158
159    def __str__(self) -> str:
160        """Represent the destination spec as a string."""
161        if self.is_template:
162            assert isinstance(self.format, jinja2.Template)  # noqa: S101  # 👈 for static analysis
163            fmt = f'(template: {self.format.name})'
164        else:
165            fmt = f'(format: {self.format})'
166
167        return f'{fmt} -> {self.path}'
168
169    @classmethod
170    def from_primitives(cls, data: Dest) -> DestSpec:
171        """Create a `DestSpec` instance from a dictionary of primitive types."""
172        path = Path(data['path'])
173        if data['format'] in DUMPERS:
174            fmt: FormatT = data['format']  # type: ignore[assignment,unused-ignore]
175            return DestSpec(format=fmt, path=path)
176
177        template_path = Path(data['format'])
178
179        loader = jinja2.FileSystemLoader(template_path.parent)
180        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
181
182        return DestSpec(path=path, format=env.get_template(template_path.name))
183
184    @property
185    def is_template(self) -> bool:
186        """Whether the destination uses a Jinja2 template."""
187        return isinstance(self.format, jinja2.Template)

Container for the destination spec parsed from config-ninja's own configuration file.

DestSpec( format: Union[Literal['json', 'raw', 'toml', 'yaml', 'yml'], jinja2.environment.Template], path: pathlib.Path)
format: Union[Literal['json', 'raw', 'toml', 'yaml', 'yml'], jinja2.environment.Template]

Specify the format of the configuration file to write.

path: pathlib.Path

Write the configuration file to this path.

@classmethod
def from_primitives( cls, data: config_ninja.settings.schema.Dest) -> DestSpec:
169    @classmethod
170    def from_primitives(cls, data: Dest) -> DestSpec:
171        """Create a `DestSpec` instance from a dictionary of primitive types."""
172        path = Path(data['path'])
173        if data['format'] in DUMPERS:
174            fmt: FormatT = data['format']  # type: ignore[assignment,unused-ignore]
175            return DestSpec(format=fmt, path=path)
176
177        template_path = Path(data['format'])
178
179        loader = jinja2.FileSystemLoader(template_path.parent)
180        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
181
182        return DestSpec(path=path, format=env.get_template(template_path.name))

Create a DestSpec instance from a dictionary of primitive types.

is_template: bool
184    @property
185    def is_template(self) -> bool:
186        """Whether the destination uses a Jinja2 template."""
187        return isinstance(self.format, jinja2.Template)

Whether the destination uses a Jinja2 template.

@dataclasses.dataclass
class ObjectSpec:
221@dataclasses.dataclass
222class ObjectSpec:
223    """Container for each object parsed from `config-ninja`_'s own configuration file.
224
225    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
226    """
227
228    dest: DestSpec
229    """Destination metadata for the object's output file."""
230
231    hooks: tuple[Hook, ...]
232    """Zero or more `poethepoet` tasks to execute as callback hooks."""
233
234    source: SourceSpec
235    """Configuration for the object's `config_ninja.backend.Backend` data source."""
236
237    @staticmethod
238    def _load_hooks(data: ConfigNinjaObject, engine: HooksEngine | None) -> tuple[Hook, ...]:
239        hook_names: list[str] = data.get('hooks', [])  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
240        if hook_names and engine is None:
241            raise ValueError(f"'poethepoet' configuration must be defined for hooks in config to work: {data!r}")
242
243        return tuple(engine.get_hook(hook_name) for hook_name in hook_names)  # type: ignore[union-attr]
244
245    @classmethod
246    def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec:
247        """Create an `ObjectSpec` instance from a dictionary of primitive types.
248
249        A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not
250            provided:
251
252        >>> data = {
253        ...   'dest': {
254        ...     'path': '/dev/null',
255        ...     'format': 'yaml'
256        ...   },
257        ...   'source': {
258        ...     'backend': 'local',
259        ...     'init': {
260        ...       'kwargs': {
261        ...         'path': 'example.yaml'
262        ...       }
263        ...     }
264        ...   },
265        ...   'hooks': ['foo']
266        ... }
267
268        >>> ObjectSpec.from_primitives(data, None)
269        Traceback (most recent call last):
270        ...
271        ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ...
272        """
273        return ObjectSpec(
274            dest=DestSpec.from_primitives(data['dest']),
275            hooks=cls._load_hooks(data, engine),
276            source=SourceSpec.from_primitives(data['source']),
277        )

Container for each object parsed from config-ninja's own configuration file.

ObjectSpec( dest: DestSpec, hooks: tuple[config_ninja.hooks.Hook, ...], source: SourceSpec)
dest: DestSpec

Destination metadata for the object's output file.

hooks: tuple[config_ninja.hooks.Hook, ...]

Zero or more poethepoet tasks to execute as callback hooks.

source: SourceSpec

Configuration for the object's config_ninja.backend.Backend data source.

@classmethod
def from_primitives( cls, data: config_ninja.settings.schema.ConfigNinjaObject, engine: config_ninja.hooks.HooksEngine | None) -> ObjectSpec:
245    @classmethod
246    def from_primitives(cls, data: ConfigNinjaObject, engine: HooksEngine | None) -> ObjectSpec:
247        """Create an `ObjectSpec` instance from a dictionary of primitive types.
248
249        A `ValueError` is raised when hooks are referenced by the `ConfigNinjaObject` but the `HooksEngine` is not
250            provided:
251
252        >>> data = {
253        ...   'dest': {
254        ...     'path': '/dev/null',
255        ...     'format': 'yaml'
256        ...   },
257        ...   'source': {
258        ...     'backend': 'local',
259        ...     'init': {
260        ...       'kwargs': {
261        ...         'path': 'example.yaml'
262        ...       }
263        ...     }
264        ...   },
265        ...   'hooks': ['foo']
266        ... }
267
268        >>> ObjectSpec.from_primitives(data, None)
269        Traceback (most recent call last):
270        ...
271        ValueError: 'poethepoet' configuration must be defined for hooks in config to work: ...
272        """
273        return ObjectSpec(
274            dest=DestSpec.from_primitives(data['dest']),
275            hooks=cls._load_hooks(data, engine),
276            source=SourceSpec.from_primitives(data['source']),
277        )

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: ...
@dataclasses.dataclass
class SourceSpec:
190@dataclasses.dataclass
191class SourceSpec:
192    """The data source of a `config-ninja`_ object.
193
194    .. _config-ninja: https://config-ninja.readthedocs.io/home.html
195    """
196
197    backend: Backend
198    """Read configuration data from this backend (see `config_ninja.contrib` for supported backends)."""
199
200    format: FormatT = 'raw'
201    """Decode the source data from this format."""
202
203    @classmethod
204    def from_primitives(cls, data: Source) -> SourceSpec:
205        """Create a `SourceSpec` instance from a dictionary of primitive types.
206
207        If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is
208        invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing
209        arguments to the `config_ninja.backend.Backend`'s `__init__()` method.
210        """
211        backend_class = get_backend(data['backend'])
212        fmt = data.get('format', 'raw')
213        if new := data.get('new'):
214            backend = backend_class.new(**new['kwargs'])
215        else:
216            backend = backend_class(**data['init']['kwargs'])
217
218        return SourceSpec(backend=backend, format=fmt)

The data source of a config-ninja object.

SourceSpec( backend: config_ninja.backend.Backend, format: Literal['json', 'raw', 'toml', 'yaml', 'yml'] = 'raw')

Read configuration data from this backend (see config_ninja.contrib for supported backends).

format: Literal['json', 'raw', 'toml', 'yaml', 'yml'] = 'raw'

Decode the source data from this format.

@classmethod
def from_primitives( cls, data: config_ninja.settings.schema.Source) -> SourceSpec:
203    @classmethod
204    def from_primitives(cls, data: Source) -> SourceSpec:
205        """Create a `SourceSpec` instance from a dictionary of primitive types.
206
207        If the given `Source` has a `Source.new` key, the appropriate `config_ninja.backend.Backend.new()` method is
208        invoked to create `SourceSpec.backend`. Otherwise, the `Source` must have a `Source.init` key for passing
209        arguments to the `config_ninja.backend.Backend`'s `__init__()` method.
210        """
211        backend_class = get_backend(data['backend'])
212        fmt = data.get('format', 'raw')
213        if new := data.get('new'):
214            backend = backend_class.new(**new['kwargs'])
215        else:
216            backend = backend_class(**data['init']['kwargs'])
217
218        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.

def load(path: pathlib.Path) -> config_ninja.settings.Config:
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 HooksEngine, exceptions
124    except ImportError as exc:
125        logger.debug('%s: %s', exc.name, exc.msg)
126        return Config(engine=None, settings=pyspry.Settings.load(path, PREFIX))
127
128    try:
129        engine = HooksEngine.load_file(path, DEFAULT_PATHS)
130    except exceptions.PoeException:
131        # this is expected if the file does not define any hooks
132        logger.debug('could not load `poethepoet` hooks from %s', path, exc_info=True)
133        engine = None
134    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
def resolve_path() -> pathlib.Path:
137def resolve_path() -> Path:
138    """Return the first path in `DEFAULT_PATHS` that exists."""
139    for path in DEFAULT_PATHS:
140        if path.is_file():
141            return path
142
143    raise FileNotFoundError('Could not find config-ninja settings', DEFAULT_PATHS)

Return the first path in DEFAULT_PATHS that exists.