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_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/docs/checkouts/readthedocs.org/user_builds/config-ninja/checkouts/v1.5.0-alpha.0/config-ninja-settings.yaml'), PosixPath('/home/docs/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
@dataclasses.dataclass
class DestSpec:
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.

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

is_template: bool
171    @property
172    def is_template(self) -> bool:
173        """Whether the destination uses a Jinja2 template."""
174        return isinstance(self.format, jinja2.Template)

Whether the destination uses a Jinja2 template.

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

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

Destination metadata for the object's output file.

hooks: tuple[config_ninja.settings.poe.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.settings.poe.HooksEngine | None) -> ObjectSpec:
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.

PREFIX = 'CONFIG_NINJA'

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

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

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

def load(path: pathlib.Path) -> config_ninja.settings.Config:
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.

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