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_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:
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.

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:
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.

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

Whether the destination uses a Jinja2 template.

@dataclasses.dataclass
class ObjectSpec:
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.

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:
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: ...
@dataclasses.dataclass
class SourceSpec:
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.

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:
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.

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 (  # 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
def resolve_path() -> pathlib.Path:
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.