Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/How-to/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ example, the `mors` stellar evolution module (`star.module = 'mors'`) requires
loader reports an error at startup if a required parameter is missing for the
chosen modules.

**Keys outside the schema are refused.** Loading a configuration fails if the
file contains a key that the schema does not define, and the error names every
such key. Because each parameter has a default, a misspelled or outdated option
name would otherwise take no effect at all: the run would proceed on the
default while the file appears to say otherwise. The check covers nested keys,
so `planet.elements.H_budgets` is caught as readily as a stray top-level
section. It runs when a run configuration is loaded, which includes `proteus
start`, the data-download commands, and the base configuration of a parameter
grid. If a key is rejected that you expect to exist, check its spelling against
the configuration reference pages above or against `all_options.toml`.

**Sections must be written as single tables.** Writing `[[planet]]` rather than
`[planet]` declares an array of tables, which the parser cannot map onto the
schema: the section is discarded whole and every parameter inside it reverts to
its default. The name itself is spelled correctly in that case, so nothing
about it looks wrong in the file. Loading refuses such a section and names it,
and the fix is to remove the extra brackets.

See [`all_options.toml`](https://github.com/FormingWorlds/PROTEUS/blob/main/input/all_options.toml) for a comprehensive example. Have a look at the other [input configs](https://github.com/FormingWorlds/PROTEUS/tree/main/input) for ideas of how to set up your config in practice.

## Root parameters
Expand Down
21 changes: 19 additions & 2 deletions src/proteus/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _should_apply_deterministic(argv, environ) -> bool:

from proteus import Proteus # noqa: E402
from proteus import __version__ as proteus_version # noqa: E402
from proteus.config import read_config_object # noqa: E402
from proteus.config import UnknownConfigKeyError, read_config_object # noqa: E402
from proteus.utils.data import download_sufficient_data # noqa: E402
from proteus.utils.helper import get_proteus_dir, resolve_fwl_data_dir # noqa: E402
from proteus.utils.logs import bootstrap_logger, setup_logger # noqa: E402
Expand All @@ -89,7 +89,24 @@ def _should_apply_deterministic(argv, environ) -> bool:
)


@click.group()
class ConfigAwareGroup(click.Group):
"""Command group that presents a refused configuration as a CLI error.

Every command that reads a configuration can refuse it over unrecognised
keys. Catching that here rather than in each command keeps the message in
the same style as the rest of the CLI, and reaches subcommands too, since
they are invoked through this group. Only the configuration-key error is
caught, so an unrelated failure still surfaces with its traceback.
"""

def invoke(self, ctx):
try:
return super().invoke(ctx)
except UnknownConfigKeyError as exc:
raise click.ClickException(str(exc)) from exc


@click.group(cls=ConfigAwareGroup)
@click.version_option(version=proteus_version)
def cli():
# Ensure the 'fwl' logger has a handler as early as possible, before any
Expand Down
82 changes: 71 additions & 11 deletions src/proteus/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import cattrs

from ._config import Config
from .orphans import check_config_orphan_free
from .orphans import UnknownConfigKeyError, find_key_problems, format_orphan_message

log = logging.getLogger('fwl.' + __name__)

Expand All @@ -21,16 +21,36 @@ def read_config(path: Path | str) -> dict:
return config


def read_config_object(path: Path | str) -> Config:
"""Read and validate config into Config object."""
def structure_config(raw: dict, path: Path | str) -> Config:
"""Structure a raw config dict into a Config object.

# Read config from TOML file in path as a raw dict.
cfg = read_config(path)
This performs no key checking: cattrs discards anything the schema does not
map, so a caller that uses this directly is responsible for having checked
the keys itself. `read_config_object` is the checked entry point and is
what almost every caller wants. The step is separate so the runner can
obtain a configuration, resolve the output directory named inside it, and
record a refusal there before raising.

Parameters
----------
raw:
Raw TOML dict as returned by `read_config`.
path:
Config file the dict came from, quoted back in any error.

Returns
-------
Config
The structured configuration.

Raises
------
ValueError
If a value fails validation.
"""

# Attempt to structure config with cattrs.
try:
# Structure the config
obj = cattrs.structure(cfg, Config)
obj = cattrs.structure(raw, Config)
log.debug(
'Config structured: star.module=%s, interior_energetics.module=%s, '
'outgas.module=%s, atmos_clim.module=%s, escape.module=%s',
Expand All @@ -40,8 +60,6 @@ def read_config_object(path: Path | str) -> Config:
obj.atmos_clim.module,
obj.escape.module,
)

# Looks good! Return the structured config object.
return obj

# Catch validation exceptions
Expand All @@ -61,4 +79,46 @@ def read_config_object(path: Path | str) -> Config:
) from None


__all__ = ['Config', 'read_config_object', 'read_config', 'check_config_orphan_free']
def read_config_object(path: Path | str) -> Config:
"""Read and validate config into Config object.

Parameters
----------
path:
Path to the TOML config file.

Returns
-------
Config
The structured configuration.

Raises
------
UnknownConfigKeyError
If the file carries keys the schema cannot accept.
ValueError
If a value fails validation.
"""

# Read config from TOML file in path as a raw dict.
cfg = read_config(path)

# Reject unusable keys before structuring, so that a typo is reported as a
# typo rather than as whatever the resulting default happens to break
# further downstream.
orphans, mistyped = find_key_problems(cfg)
if orphans or mistyped:
raise UnknownConfigKeyError(format_orphan_message(orphans, path, mistyped))

return structure_config(cfg, path)


__all__ = [
'Config',
'UnknownConfigKeyError',
'read_config_object',
'read_config',
'structure_config',
'find_key_problems',
'format_orphan_message',
]
Loading
Loading