# tephpy configuration files — design specification > **Living document.** This specification is maintained alongside the code, not archived > behind it. `src/tephpy/_configfile.py` and `src/tephpy/_cli.py` cite it by section — > `configfile spec §3.2` and the like — so these sections *are* the reasoning behind what the > code does, and where the two ever diverge it is the specification that gets corrected. > Read it as current. - **Date:** 2026-08-07 (originated; maintained since) - **Status:** living design specification, implemented in {pull}`112` - **Citation prefix:** `configfile spec §…` — deliberately not `config spec`, which would read as a near-duplicate of the parent's own spec §3.5 on `tephpy.config`; the prefix matches the module it governs - **Scope:** a YAML configuration file for `tephpy.config`, its discovery cascade, and a `tephpy config` console script that generates and locates it - **Parent spec:** [`2026-07-22-tephpy-design.md`](2026-07-22-tephpy-design.md) — this extends spec §3.5 with a persistence tier beneath `tephpy.config`, and inherits its error-handling (spec §6), testing (spec §7) and engineering-standards (spec §8) rules unchanged - **Prior art:** matplotlib's `matplotlibrc` discovery cascade; the XDG Base Directory Specification, via [`platformdirs`](https://platformdirs.readthedocs.io/) (configfile-spec-1)= ## 1. Purpose `tephpy.config` already lets a user restyle every isopleth family, but only from Python and only for the lifetime of the process. Anyone with a house style — a colour scheme, a preferred extent, a cursor readout — retypes it at the top of every script: ```python tephpy.config.isotherms.color = "purple" tephpy.config.isobars.linewidth = 0.8 tephpy.config.diagram.extent = ((1000.0, -30.0), (250.0, 35.0)) ``` This specification gives that boilerplate a home on disk: ```console $ tephpy config generate ``` writes a fully-populated, fully-commented template; the user uncomments what they want; every subsequent `import tephpy` picks it up. Nothing about the existing API changes — the file is a new *bottom* tier, not a new front door. (configfile-spec-2)= ## 2. Decisions | Decision | Choice | Rationale | |---|---|---| | Format | **YAML** | Comments are the requirement that decides it. A template whose value lines are commented out, each above its own prose description, is the entire user experience being asked for — and JSON has no comments at all. TOML has comments, but no clean spelling for the float-keyed mappings `emphasis` needs: TOML table keys are strings, so every member value would be quoted (§3.3) | | Parser | `yaml.safe_load` | Never `yaml.load`: a config file is exactly the untrusted-input case full-loader tag construction is dangerous for | | Discovery | matplotlibrc-style cascade, **first hit wins** (§3.2) | A convention users of the scientific Python stack already hold. First-hit-wins beats merging: with merge, a value you cannot see in the file you are editing can override the one you can | | Location | `platformdirs.user_config_dir` | Correct on all three platforms without a per-platform branch. Hand-rolling `~/.config` is wrong on Windows and macOS | | When loaded | At **`import tephpy`**, once | Matches the precedence contract in spec §3.5: config must already be in place before the first family is created, and families read config at creation | | Auto-load failure | **Warns**, never raises (§5) | A YAML typo must not make `tephpy` unimportable — that would also take out `tephpy config path`, the tool for diagnosing it | | Explicit load failure | **Raises** | A direct question deserves a direct answer | | Unknown *option* | Warn, skip, continue | Forward compatibility: a file written for a later tephpy stays usable | | Unknown *section* | Raise | A mistyped section silently discards every option under it — too much to lose to a warning | | Wrong-typed *option value* | Warn, skip, continue (§5.2) | The same reasoning as an unknown option, and the same blast radius: one bad line must not cost the user the rest of the file. `int` is accepted where `float` is declared, because `linewidth: 1` is not a mistake; `bool` is not, because `linewidth: true` is | | Template defaults | A declarative `CONFIG_DEFAULTS` table in `_constants.py`, gated against `_resolve()` (§3.4) | Rendering the template must not re-enter the plotting path. A second table is only safe with a gate, so the gate is part of the design, not a follow-up | | Option descriptions | Two tables in `_configfile.py`, gated for completeness (§3.4) | The `#:` comments in `_config.py` are invisible at runtime — they are not docstrings — and unpublishable besides: `_config` is private and autoapi parses statically, so a `#:` comment there reaches no reader at all. `CONFIG_DESCRIPTIONS` carries the one-line summary both renderings show; `CONFIG_DETAILS` carries the longer prose only the reference page has room for (§3.6) | | Options reference page | Generated from the same tables at build time (§3.6) | Prose can only cross-reference an option that has a target, and the options live in the private `_config`, which autoapi does not document. Generating the page from `CONFIG_DEFAULTS` makes it drift-proof by construction and keeps `_config` private. The alternatives — re-exporting the dataclasses from a public module, or adding a private module to the autoapi list — each publish an implementation detail to buy the same targets (§9) | | Template line width | Wrapped to 88 columns; value lines never wrapped (§3.6) | 88 is the width ruff holds this repository's own sources to. A commented value line has to survive uncommenting as a single line, so wrapping one would hand a user who uncomments only its first line broken YAML | | CLI framework | **click**, documented by `sphinx-click` | Zero runtime dependencies of its own. The alternative pairing (typer + sphinxcontrib-typer) adds five transitive runtime dependencies for a two-subcommand CLI | | Default subcommand | `invoke_without_command=True` + `ctx.invoke(path)` | **Measured:** stock click covers the zero-argument default; `click-default-group` earns its place only for forwarding *arguments* to an unnamed default, which this CLI never needs. Defaulting to `path` rather than `generate` also means a bare `tephpy config` can never write a file | | Test isolation | `Config.reset()` + a conftest hook (§6) | A shipped `tephpytestrc.yaml` pinning the defaults was considered and rejected: it would route every image baseline through the YAML path, add a third defaults table, and mask accidental default changes that baselines exist to catch | | `save()` fidelity | Values only — **comments and ordering are lost** | PyYAML cannot round-trip comments. Stated as a limitation rather than designed around; `generate` is the commented artefact, `save` is a data dump | (configfile-spec-3)= ## 3. Architecture ``` src/tephpy/ _constants.py conventions + CONFIG_DEFAULTS (new table) _config.py shape: the dataclasses, context(); + source, reset(), load(), save() _configfile.py NEW — discovery, parse, coerce, validate, render, write _cli.py NEW — click group; argument parsing and output text only __init__.py + the auto-load hook ``` Each module has one job, and the dependency arrows run one way: (`_cli`, `_config`) → `_configfile` → `_constants`. `_config` depends on `_configfile` inherently — `load()` and `save()` are methods on `Config` — and `_configfile` needs `Config` only as an annotation, imported under `TYPE_CHECKING`, so the arrow between them is one-way and there is no import cycle to work around. Nothing in `_configfile` imports `plotting`, so loading a config file cannot drag in matplotlib figure machinery, and `_cli` holds no logic that is unreachable from Python. (configfile-spec-3-1)= ### 3.1 Two constraints from the existing code **`source` must not be a dataclass field.** `Config.context()` enumerates its valid sections with `dataclasses.fields(self)` and raises `TypeError` for anything else. Any annotated class attribute becomes a field, so `_source: Path | None = None` at class level would present `source` as an eighth configuration section and break `context()`. It is therefore set in `__post_init__`, with no class-level annotation, and exposed through a read-only property. **The template must not re-enter `_resolve()`.** `_resolve()` is on the path every image baseline covers. The template generator reads `CONFIG_DEFAULTS` instead, and a gate keeps the two honest (§3.4). (configfile-spec-3-2)= ### 3.2 Discovery cascade First hit wins; discovery stops at the first path that exists. 1. `$TEPHPYRC`, if set 2. `./tephpyrc.yaml` in the current working directory 3. `platformdirs.user_config_dir("tephpy")/tephpyrc.yaml` If none exists, tephpy runs on its hardwired conventions and `config.source` is `None` — the no-config case is normal, not an error. `$TEPHPYRC` is the one entry whose absence is an error: setting it names a specific file, so pointing it at a missing path is reported rather than falling through to entry 2 — falling through would silently ignore an explicit instruction. Whether that report is a warning or an exception follows the one rule in §5, like every other config-file problem: auto-load warns, explicit load raises. That rule is expressed once, and the split between the two functions is not symmetric. `config_paths()` reports the cascade *including* entries that do not exist — `tephpy config path` marks a missing named file `[absent]`, which is how a user diagnoses a typo in the variable — so the "absent is an error" half belongs to `discover()` alone. What both need is the answer to "which path does `$TEPHPYRC` name", and they take it from one helper, resolved once per call. `discover()` therefore validates and returns the same path: reading the environment twice, as it once did, left a window in which the file checked for existence and the file returned were two different files. (configfile-spec-3-3)= ### 3.3 File format One top-level mapping per configuration section, mirroring `Config` exactly — seven sections, 42 options: | Section | Type | Options | |---|---|---| | `isotherms`, `isobars`, `dry_adiabats` | `FamilyOptions` | `color`, `linewidth`, `alpha`, `labels`, `visible`, `emphasis`, `values`, `interval` | | `moist_adiabats` | `MoistAdiabatOptions` | the above + `truncation` | | `mixing_ratios` | `MixingRatioOptions` | `LineOptions` + `values` — a values ladder only, so **no** `interval` | | `diagram` | `DiagramOptions` | `extent` | | `cursor` | `CursorOptions` | `fields` | ```yaml isotherms: color: dimgrey linewidth: 0.5 alpha: 1.0 labels: true visible: true # interval: omitted — the zoom-adaptive ladder selects members emphasis: 0.0: {color: tab:cyan, linewidth: 1.5} diagram: extent: [[1050.0, -40.0], [200.0, 40.0]] cursor: fields: [pressure, temperature, theta] ``` Four coercions are needed because YAML's type model does not match the dataclasses': | YAML gives | Wanted | Note | |---|---|---| | `list` | `tuple` | `labels`, `values`, `fields`, `extent` | | nested `list` | nested `tuple` | `extent` is `((p, T), (p, T))` | | `int` mapping key | `float` | `emphasis` is keyed by member value; `850` and `850.0` must not be two members | | scalar `str` | `str` | `labels` accepts a bare edge name as well as a tuple | `interval` and `values` have **no** default value. Leaving them unset is what enables the zoom-adaptive selection ladder, so the generated template carries them as commented prose, never as a number — writing a plausible-looking default there would silently disable adaptive selection for every user who uncommented it. (configfile-spec-3-4)= ### 3.4 The declarative tables and their gates `CONFIG_DEFAULTS` is a declarative `{section: {option: default}}` table in `_constants.py`, read by the two renderings of §3.6 and by nothing else. It records *effective* defaults — what the user actually gets — which for most options is the `_constants` convention `_resolve()` falls back to (`ISOPLETH_LINEWIDTH`, `ISOPLETH_ALPHA`, the per-family `spec.color`, `visible=True`), and for `interval`/`values` is the absence of one. Two description tables sit beside it in `_configfile.py`, and they are two registers rather than two copies. `CONFIG_DESCRIPTIONS` gives every option a one-line summary, keyed per `(section, option)` so a family can name its own units — hPa for isobars, degrees Celsius for the temperature families, g/kg for mixing ratios — with `_LINE_DESCRIPTIONS` supplying once the five options that mean the same thing for every family. `CONFIG_DETAILS` is sparse: an option earns an entry only where there is behaviour a summary cannot carry, and the reference page is the only rendering with room to show it. Being second copies of what the dataclasses declare, the tables need gates: - **Defaults gate:** for every `(section, option)`, `CONFIG_DEFAULTS` matches what `_resolve()` returns with empty kwargs and an empty config. - **Description gate:** every option in `CONFIG_DEFAULTS` has a description, and no description is an orphan. - **Markup gate:** the descriptions are dual-register — a paragraph of reStructuredText on the reference page, a plain-text YAML comment in the template — so they carry exactly one construct, the double-backquoted literal, and no `*`, `|`, `--` or trailing underscore. A value the reader types is written as a literal, so that it stands out on the page instead of blending into the sentence around it; `_unmarked()` strips that one construct for the template, and the gate holds both ends — no backquote survives into the template, and the vocabulary itself does. Any other markup would reach the template as itself, which is why the escape is a single construct and not a general one. - **Detail gate:** every `CONFIG_DETAILS` key names a real option, so a detail cannot outlive the option it describes. There is deliberately no converse: the table is sparse. - **Coverage gate:** the targets `render_reference()` emits are exactly `tephpy.config.
.