tephpy configuration files — design specification#
Living document. This specification is maintained alongside the code, not archived behind it.
src/tephpy/_configfile.pyandsrc/tephpy/_cli.pycite it by section —configfile spec §3.2and 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 #112
Citation prefix:
configfile spec §…— deliberately notconfig spec, which would read as a near-duplicate of the parent’s own spec §3.5 ontephpy.config; the prefix matches the module it governsScope: a YAML configuration file for
tephpy.config, its discovery cascade, and atephpy configconsole script that generates and locates itParent spec:
2026-07-22-tephpy-design.md— this extends spec §3.5 with a persistence tier beneathtephpy.config, and inherits its error-handling (spec §6), testing (spec §7) and engineering-standards (spec §8) rules unchangedPrior art: matplotlib’s
matplotlibrcdiscovery cascade; the XDG Base Directory Specification, viaplatformdirs
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:
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:
$ 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.
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 |
Parser |
|
Never |
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 |
|
Correct on all three platforms without a per-platform branch. Hand-rolling |
When loaded |
At |
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 |
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. |
Template defaults |
A declarative |
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 |
The |
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 |
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 |
Zero runtime dependencies of its own. The alternative pairing (typer + sphinxcontrib-typer) adds five transitive runtime dependencies for a two-subcommand CLI |
Default subcommand |
|
Measured: stock click covers the zero-argument default; |
Test isolation |
|
A shipped |
|
Values only — comments and ordering are lost |
PyYAML cannot round-trip comments. Stated as a limitation rather than designed around; |
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.
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).
3.2 Discovery cascade#
First hit wins; discovery stops at the first path that exists.
$TEPHPYRC, if set./tephpyrc.yamlin the current working directoryplatformdirs.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.
3.3 File format#
One top-level mapping per configuration section, mirroring Config exactly — seven
sections, 42 options:
Section |
Type |
Options |
|---|---|---|
|
|
|
|
|
the above + |
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|
|
|
|
nested |
nested |
|
|
|
|
scalar |
|
|
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.
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_DEFAULTSmatches what_resolve()returns with empty kwargs and an empty config.Description gate: every option in
CONFIG_DEFAULTShas 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_DETAILSkey 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 exactlytephpy.config.<section>.<option>for every option inCONFIG_DEFAULTS. Generating the page from the table makes it drift-proof, but not non-empty — this is the gate a renderer that silently produced nothing would fail, and the docs build would not.Type-text gate: no rendered
:type:carries a bare name that is neither a builtin nor dotted (§3.6).Width gate: no line
render_template()produces exceeds 88 columns. The literal is written in the test rather than imported from the renderer, so raising one width does not silently move the other.
Every gate asserts its own parameter list is non-empty, and each gate that enumerates
sections asserts the section set equals {f.name for f in dataclasses.fields(Config)} — a
gate whose input silently emptied would otherwise pass by checking nothing. The detail gate
is the exception on the second count, and only there: a sparse table has no section set to
compare.
3.5 Public API#
Name |
Behaviour |
|---|---|
|
|
|
Restore the pristine hardwired state; |
|
Load |
|
Write the options the user actually set (non- |
save() writes values only. Comments and key order in an existing file are not
preserved — PyYAML cannot round-trip them. generate is the commented artefact; save is
a data dump, and the docs say so.
3.6 Two renderings of one table#
The CONFIG_DEFAULTS, CONFIG_DESCRIPTIONS and CONFIG_DETAILS triple of §3.4 is
rendered twice, by two functions in _configfile.py:
Rendering |
Output |
Shows |
|---|---|---|
|
the commented YAML |
summary, option, effective default |
|
reStructuredText for the options reference page |
summary, detail, type, effective default, method example |
Wrapping. render_template() wraps each summary with textwrap.fill, at width 88
counting the # prefix that initial_indent and subsequent_indent carry. Before this,
eleven lines ran over — the longest 117 columns — because a description that reads
comfortably in the source table overruns once it is commented and indented. Value lines are
deliberately not wrapped: # emphasis: {} has to survive uncommenting as a single line,
and a wrapped flow-style value would leave a user who uncomments only its first line with
broken YAML. The widest value line is 44 columns, so the width gate has headroom; should a
future default ever cross 88, the answer is a judgement about that default’s rendering, not
automatic wrapping.
Why the renderer ships. render_reference() lives in the package rather than in the
Sphinx extension that calls it. Its gates are then ordinary tests that import tephpy, with
no sys.path manipulation and no test that fails to collect from an unpacked sdist.
docs/src/_ext/tephpy_config_reference.py reduces to a directive,
.. tephpy-config-options::, which calls the function and parses what it returns. Nothing
Sphinx-side is imported by the package.
Its signature is render_reference(config: Config) -> str, taking the instance from its
caller exactly as apply() does. That is not a stylistic choice: _config imports
_configfile at module scope (§3), so _configfile may hold Config only as a
TYPE_CHECKING annotation. Passing the instance in is what keeps the arrow one-way; a
function-local import of _config would work at runtime and quietly reverse it.
Targets. Each option becomes
.. py:attribute:: tephpy.config.isobars.emphasis
:type: collections.abc.Mapping[float, collections.abc.Mapping[str, object]] | None
so prose can write :attr:`tephpy.config.isobars.emphasis`. The page declares nothing at
tephpy.config itself: config is in the package __all__ and autoapi’s undoc-members
documents it as a data member of tephpy, so a second declaration would be a duplicate
object and, under --fail-on-warning, a failed build. The option targets cannot collide —
autoapi parses statically and cannot see the attributes of an instance.
Types come from the evaluated annotations _option_hints() already returns for §5.2’s
validators — the same eight shapes §6’s accept/reject matrix runs over. Because
typing.get_type_hints has resolved them, str() alone yields text carrying what a source
annotation would not: Mapping arrives as collections.abc.Mapping[float, collections.abc.Mapping[str, object]], and the private Extent alias arrives expanded to
tuple[tuple[float, float], tuple[float, float]]. No qualification table is needed, and one
should not be added — it would be a second spelling of what the annotations already say, of
exactly the kind §3.4’s gates exist to catch.
What §3.4’s type-text gate guards is instead the case str() cannot render: an annotation
naming a class stringifies as <class 'tephpy._config.Thing'>, which reaches the page as
neither valid type text nor a resolvable target. That gate lives in the test suite rather
than leaning on the build, because pixi run tests has no Sphinx and never runs one;
nitpicky is the backstop for a name that is well-formed and still unresolvable.
tuple[float, ...] needs nothing — conf.py already carries ("py:class", "Ellipsis") for
the existing API pages.
Defaults print by one rule, with no per-option prose: the YAML form where there is one,
{} for the five emphasis mappings, which default to empty, and unset for the nine
values and interval options that have no default at all and whose summary already says
what selects their members instead (§3.3). render_template() blanks all fourteen, because
a template cannot show a value on a line meant to be uncommented; the page is under no such
constraint.
Methods. load, save, reset and context are emitted as py:method entries, with
signatures from inspect.signature and a summary from each docstring’s first line, so the
how-to can cross-reference them instead of writing them as bare literals. They are
deliberately thinner than the docstrings behind them: numpydoc runs as an autodoc hook and
this build has no autodoc, so a full numpydoc body would render its Parameters heading as
a document section.
Each also carries one worked example, from _REFERENCE_EXAMPLES in _configfile.py, emitted
as a code-block:: python beneath the summary. A signature and a sentence say what a method
is; they do not say how it is called, and for context the difference is the whole point —
context(**overrides) gives no hint that the keyword arguments are section names mapped to
{option: value} mappings. The gap is widest exactly where the how-to cannot close it:
context and reset act on the configuration in memory, so configure-from-a-file covers
neither, and before this the reference page was the only place in the documentation either
one appeared at all.
The examples are the page’s only hand-written content, and so its only content that can
drift. tests/test_docs_snippets.py does not reach them — it excludes the reference quadrant
on the grounds that a generated page cannot drift, which is true of everything else here and
not of these. tests/test_configfile_reference.py executes each one instead, reaching them
as string literals rather than as extracted page text, with membership pinned against
_REFERENCE_METHODS so that a method added without an example fails rather than passing by
absence (§6).
An example names its file. save() and load() both default to a path outside the working
directory — the user configuration directory — so an example that omitted the argument would,
when executed, read or write the developer’s own configuration. The argument is in the prose
because it has to be in the test, and the defaults are left to the summary and the how-to.
4. Command-line interface#
Console script tephpy, declared in [project.scripts], with a config group so later
subcommands have somewhere to live.
$ tephpy config # same as `tephpy config path`
$ tephpy config path # print the cascade, marking the active file
$ tephpy config generate # write the template to the user config dir
$ tephpy config generate -o FILE # write to FILE ("-" for stdout)
$ tephpy config generate --force # overwrite an existing file
path prints every cascade entry in order, marks the one in force, and shows which are
absent — it is the tool a user reaches for when a config file “isn’t working”, so it must
report the whole search, not just the winner.
generate refuses to overwrite without --force, exiting 1 and naming the file it would
have clobbered.
5. Error handling#
Extends spec §6 with two names in exceptions.py:
Name |
Base |
Raised/warned when |
|---|---|---|
|
|
Explicit load of a malformed file; unknown section; |
|
|
Auto-load failure; unknown option; an explicit null value; a wrong-typed value (§5.2) |
The governing rule is auto-load warns, explicit load raises (§2). On auto-load failure
the config is left pristine and source stays None, so a broken file degrades to the
hardwired defaults rather than to a half-applied state.
Warning on an explicit null deserves its rationale, since it looks over-zealous. PyYAML parses
color: #b0b0b0
as None — the unquoted hex colour is consumed as a comment. Measured: color: #b0b0b0
→ {'color': None}, silently. A null in a config file is far more often this typo than a
deliberate value, and the two are indistinguishable after parsing, so the warning names the
missing quotes as the likely cause.
A file commented out in its entirety parses to None, not {} — that is the intended way
to start from the generated template, and it is an empty config, not an error.
A section whose options are all commented out parses the same way, to {"isotherms": None},
and is likewise an empty section rather than an error. This is the one null that does not
warn, and the distinction is deliberate: the generated template leaves section headers
uncommented so that uncommenting a single option needs no second edit, which makes a null
section the expected state of every section the user has not touched. A null option value
stays a warning, because nothing in the template produces one.
5.1 Warning provenance#
A TephpyConfigWarning reports a mistake in the user’s file, so it must point at the
user’s own code — the config.load(...) call, or the import tephpy that ran the
auto-load. A warning that names a file inside src/tephpy/ reads as a tephpy bug and gives
the user nothing to edit. Every configuration warning therefore routes through one private
helper, _configfile._warn_from_caller, which passes skip_file_prefixes rather than
stacklevel: warnings.warn walks outwards to the first frame whose filename does not
start with the tephpy package directory, and blames that (#107).
stacklevel is the wrong instrument here because it is a fixed count and the depth is not
fixed. apply is reached from Config.load, from the import-time auto-load, from the
CLI’s _applies, and directly from the tests — four depths, one number, so any choice is
right for one caller and wrong for the rest. The import path is not merely a different
count: the user’s import tephpy sits behind importlib’s frozen bootstrap frames, so no
integer reaches it at all. skip_file_prefixes names the frames to skip instead, which
makes attribution follow from where tephpy ends rather than from how deep the call went.
Measured on Python 3.12.3 — the declared floor, and skip_file_prefixes is a 3.12
addition, so the mechanism is available across the whole supported range:
Call path |
|
|
|---|---|---|
|
|
the caller’s |
|
|
the caller’s |
|
the caller |
the caller |
Two consequences follow from attribution being depth-independent. Routing all three warning
sites through a shared helper costs nothing — the frame the helper adds is inside the
package and skipped like any other, where the same refactor under stacklevel would have
meant re-counting every call site. And the prefix must carry a trailing os.sep: matching
is a plain string compare, so a bare package directory would also match a sibling
tephpy_extras/.
_cli._applies suppresses TephpyConfigWarning outright for the duration of its probe,
and that suppression is deliberate rather than incidental. There is no user frame worth
blaming: the call originates in tephpy’s own CLI, against a file the user asked to locate
rather than to load (§4), and import tephpy has already warned over the same cascade.
One consequence for downstream code, which is why it is recorded here: a filter written as
filterwarnings(..., module="tephpy") no longer matches, because the warning now belongs
to the user’s module. Filtering on category=TephpyConfigWarning is unaffected, and is the
axis the documentation points at — the category exists for exactly this.
The auto-load is the one place no filter reaches, and that is deliberate rather than a
limitation of the above. _autoload_config installs filterwarnings("always", category=TephpyConfigWarning) for the duration of the load, which sits in front of
everything the user set, so the import-time notice is shown whatever their filters say —
the same mechanism that stops -W error turning a typo’d option into a failed import. User
code has not started running by then in any case. Only a later explicit config.load(...)
is the user’s to filter, so that is the call the how-to shows alongside the filter.
5.2 Wrong-typed values#
A configuration value must match the type its Config field declares. linewidth is
float | None, so linewidth: thick is a mistake in the user’s file, and one the loader
is in a position to catch. It did not: coerce converted the four shapes YAML forces
(§3.3) and passed everything else through untouched (#105).
Measured against the implementation as of #112 — one class of mistake, three different behaviours, none of them the one §2 asks for:
File |
Outcome |
|---|---|
|
loads silently; |
|
loads silently; matplotlib rejects the colour at the first draw |
|
loads silently, then iterates the string — |
|
loads silently and draws: matplotlib reads |
|
loads silently and draws: a non-empty string is truthy |
|
loads silently and draws: the cursor readout is quietly wrong |
|
raises, discarding every other option in the file |
The last four rows are the ones that decide the design. Three of them are silent wrongness
— no warning, no traceback, a diagram that is simply not what the file asked for. The
fourth is the mirror image: coerce raising escalates an option-level problem into a
file-level one, which the table above never sanctioned. Under auto-load that escalation
means a single mistyped extent leaves every other option in the file unapplied.
The rule. A value whose type does not match its field is reported and skipped; the option keeps its default, and the rest of the file still applies. This is not a new principle — it is the existing one (§2: option-level problems warn and skip, section-level problems reject the file) applied to a case §2 did not enumerate.
Two adjustments to “matches the declared type”, both forced by YAML:
intis accepted wherefloatis declared.linewidth: 1is not a mistake, and nothing in the file format tells the user they were supposed to write1.0.boolis not.isinstance(True, int)isTruein Python, so a boolean reaches a numeric field unless it is excluded explicitly — which is precisely howlinewidth: truecame to draw a 1 pt line. YAML 1.1 widens the exposure: PyYAML readsyes,no,onandoffas booleans too.
Implementation. The expected types are not written out a second time; they are read from the annotations that already exist:
The expected type reaches
coerceas an argument.applyresolves it from the section it already holds, through_option_hints, a thin wrapper ontyping.get_type_hintswhich resolves cleanly through thefrom __future__ import annotationsin_config.py. A module-level{(section, option): annotation}table is not available:_configfilecannot import_configat runtime without reversing the §3 dependency arrow, and a lazily-built one would leave a directcoercecaller checking against an empty table. Measured: 42 options over 8 distinct annotation shapes._TYPE_VALIDATORS— one(description, converter)per distinct shape, so eight entries cover all 42 options. Each converter both checks and converts, which makes it the natural home for the §3.3 coercions rather than a second pass over the same value.coerceconsults them and raisesTephpyConfigErroron a mismatch;applycatches it, warns through_warn_from_caller(§5.1), and moves to the next option. That singleexceptis what delivers the rule and what ends the escalation.A completeness gate asserts every
(section, option)inConfighas a validator, and asserts its own option set, built fromdataclasses.fields, is non-empty and the same size as the 42 thatCONFIG_DEFAULTSholds — two independently written tables made to agree, which is the same self-check the two gates in §3.4 carry, for the same reason.
The message names the file, the option, what was expected and what was found, in the
vocabulary of the file the user is editing rather than of the annotation behind it — the
reader writes YAML and has never seen float:
/home/you/work/tephpyrc.yaml: ignoring isotherms.linewidth, which expects a number, not the string 'thick'
/home/you/work/tephpyrc.yaml: ignoring isotherms.linewidth, which expects a number, not the boolean true
/home/you/work/tephpyrc.yaml: ignoring isotherms.values, which expects a list of numbers, not the string 'notalist'
/home/you/work/tephpyrc.yaml: ignoring diagram.extent, which expects two [pressure, temperature] corners, not [1, 2]
One value fails at the conversion rather than at the check. An integer of 309 or more
digits is a number, so it passes the check; float() then raises OverflowError, which is
neither _MismatchError nor TephpyConfigError and so is caught by nothing between there
and import tephpy (§2). _as_number turns it into the same warn-and-skip as any other
mismatch. It carries the one message that describes what was found instead of naming it —
printing 401 digits back at the reader helps nobody, and “not the number” would be a lie
about why it was refused:
/home/you/work/tephpyrc.yaml: ignoring isotherms.linewidth, which expects a number, not a number that large; the largest tephpy can hold is about 1.8e308
The path prefix is new to option-level warnings, and is extended to the other two — the
unknown option and the null value — for one reason: with three cascade entries (§3.2), a
warning that names isotherms.linewidth but no file does not say which file to edit. The
file-level errors lead with the path too — apply’s two section-level raises gained it
here — so option-level and file-level messages read the same way.
A limit, stated rather than designed around. emphasis is
Mapping[float, Mapping[str, object]]: the member values and the style keys are typed, the
style values are object. So emphasis: {0.0: {linewidth: thick}} still reaches the
draw. Checking it needs to know which style keys exist and what each accepts, which is the
knowledge a domain check needs — and domain validity is out of scope here. This section
covers types only. Whether notacolour names a colour, or nonsuch a cursor field, is a
separate question, answered by domain spec §3: a second stage behind this one, which reads
the converted value and so reaches the emphasis style values this section cannot (§9).
Rejected: pydantic. A TypeAdapter per annotation — Strict() on the scalar leaves,
lax on the containers so YAML’s lists still become tuples — was measured against the rule
above and agreed with it on all 27 cases, including linewidth: 1 accepted,
linewidth: true rejected, and all four §3.3 coercions performed. It is rejected on cost
rather than on fit: a core runtime dependency and its compiled core, to replace eight small
functions, in a package whose dependency table (§7) is deliberately short. The measurement
is recorded because it sets the bar the hand-written validators are held to.
6. Testing#
Extends spec §7.
Isolation. pyproject.toml sets filterwarnings = ["error"], and the auto-load runs at
import — so a developer’s own ~/.config/tephpy/tephpyrc.yaml would feed every
pytest-mpl comparison, and one unknown key in it would become a collection error. The root
tests/conftest.py closes both:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import tephpy
tephpy.config.reset()
plus an autouse reset() per test, so a test that mutates config cannot leak into a later
baseline comparison. This depends on no new environment variable and on no assumption about
when pytest installs its warning filters.
Coverage. The genuinely new seam is YAML → Config; Config → artists is already
covered — tests/plotting/test_isopleths.py parametrises config.context(...) over
sections and options and asserts the resulting artist properties. Tests are placed
accordingly:
Test |
Proves |
|---|---|
|
Every option survives the YAML round trip |
Fixture completeness gate |
The fixture sets every option in |
Inline |
|
Subprocess import test |
|
|
|
Accept/reject matrix over the eight annotation shapes |
Each declared type accepts what it should and rejects what it should, |
Validator completeness gate |
Every option in |
One wrong-typed option beside a good one, in one file |
The warned option keeps its default and the good one applies — the case that makes “the rest of the file still applies” non-vacuous |
|
The path validated is the path returned: a stub whose second read of |
Detail, coverage, type-text and width gates (§3.4) |
A detail cannot outlive its option; the reference page names every option and no others; no |
Method examples executed, one per |
Every example on the reference page runs, against the live API rather than a transcript of it, and a method cannot be listed without one |
Three notes on why these are shaped this way. The fixture is complete rather than
representative because only completeness proves a newly added option is expressible in
YAML; a representative fixture would let a new option with an unparsable type land
unnoticed. It carries non-default values throughout because a fixture pinned to the
defaults renders identically to loading nothing at all, and would pass whether or not the
loader ran. And the auto-load needs a subprocess because tephpy is already imported by
the time any in-process test runs — that seam is invisible from inside the suite.
No new image baseline. An end-to-end image test would re-prove the covered half of the
chain, and report a loader bug as dozens of differing comparisons rather than one precise
failure. It is also incompatible with the complete fixture, which necessarily sets
visible: false and renders almost nothing.
The fixture lives under tests/, which the sdist ships, so it is available when the suite
runs from an unpacked sdist.
Every new test is mutation-proved: revert the behaviour it guards and confirm that test, and only that test, fails.
7. Dependencies#
Package |
Tier |
Floor |
Note |
|---|---|---|---|
|
core |
|
Until declared here, in the pixi environment only via pre-commit and sphinx-autoapi, and absent from a core install. Floored one patch above |
|
core |
|
Until declared here, present only via towncrier and jupyter-cache, and absent from a core install |
|
core |
|
Reachable only as a transitive of pint; declared rather than inherited |
|
docs |
|
Documents the CLI in the reference guide |
The first two matter more than a dependency table usually does. Because both are already in
the development environment, omitting the declaration leaves pixi run tests green and CI
green, while a user who runs pip install tephpy gets ImportError: No module named 'yaml'
at import. The failure is invisible to every check the repository runs.
A floor is chosen as “known to contain the APIs used”, and nothing the repository runs tests
that choice: every pixi task passes --frozen, so what CI resolves is the lockfile’s pin —
click 8.4.2 against a declared floor of >=8.1, when this was written — and a floor set too
low fails for one person only, the user who happens to resolve that version. Each
floor above is therefore resolved by hand instead, once: pyyaml, click and
platformdirs as they were declared (PR #112), sphinx-click afterwards
(2026-08-13, #109). Standing rather than closed, then — a resolve by hand settles the
floors in front of it and nothing after. What would close it is specified in the dependency
floors specification, which takes this section’s gap as its purpose (floors spec §1) and this
section’s sphinx-click result as the reason its verdicts are reported as evidence rather
than as answers (floors spec §3.5).
sphinx-click was resolved as two scratch pixi environments carrying the rest of the
documentation dependencies and differing in that one package alone — one pinned to
==6.0.0, the other left to resolve. Both build the documentation under
--fail-on-warning, with the gates of docs spec §3.6 and docs spec §3.7 green, and their
output differs on a single page: from 6.2.0 each command’s usage block carries a Usage
rubric, which 6.0.0 and 6.1.0 omit. The floor stands at >=6.0. What the reference page
asks of the directive — :prog: and :nested: full (§8) — is present there, the rubric is
presentation added upstream rather than an API tephpy calls, and the published site is
built from the lockfile rather than from the floor. The floor is declared twice — once for
pixi and once for pip — so the second was checked too: 6.0.0 ships a wheel on PyPI
declaring requires-python >=3.8, which covers every Python tephpy supports.
8. Documentation#
A Diátaxis how-to for configuring tephpy from a file, covering the cascade, the quoting trap (§5), what happens to a wrong-typed value (§5.2), and what
save()does not preserve (§3.5).A reference page for the CLI, generated by
sphinx-click.An options reference page generated from
CONFIG_DEFAULTSat build time (§3.6), carrying a target per option so the how-to and any later prose can cross-reference them with a Sphinx role instead of a bare literal, and a worked example per method — the only documentationresetandcontexthave.TephpyConfigErrorandTephpyConfigWarningpicked up by autoapi with the rest ofexceptions.featureanddependencychangelog fragments from the implementing pull request — the new dependencies (§7) are user-visible and belong in the changelog in their own right.
9. Non-goals#
This is the section that carries this specification’s unsettled items, so its entries take the status tags and issue pointers docs spec §3.5 requires. Most are settled against: a non-goal is a decision, not an omission.
Rejected (2026-08-07) — merging across cascade entries. First hit wins (§2).
Rejected (2026-08-07) — per-figure or per-axes config files.
tephpy.configis process-wide; a file changes its starting values, not its scope.Rejected (2026-08-07) — comment-preserving round trips. Would mean
ruamel.yamlin the core dependency tree to servesave()alone (§3.5).Rejected (2026-08-07) — environment-variable overrides per option.
$TEPHPYRCselects a file; it does not become a parallelTEPHPY_ISOTHERMS_COLORnamespace.Rejected (2026-08-07) — validating a config file without loading it.
tephpy config pathreports discovery; a--checkmode can follow if asked for.Rejected (2026-08-11) — making
Configand its section dataclasses public. It would buy the same cross-reference targets as §3.6 by publishing a shape §3.3 already documents as a file format, and would commit the project to that shape as API.Rejected (2026-08-11) — full method documentation on the options reference page. The
py:methodentries carry a signature, one sentence and one example (§3.6). Rendering the numpydoc bodies would mean enablingsphinx.ext.autodocalongside autoapi purely to get the hook that processes them — a second API renderer in the build, for four methods. What this rejects is that hook and theParameters,ReturnsandRaisessections it formats, not hand-written prose: the worked example §3.6 adds is a literal block in the generated reStructuredText and needs no autodoc, so it does not reopen this (#124).Rejected (2026-08-12) — a worked example for each of the 42 options. Twenty-five are scalars, where the example restates the
Default:line immediately above it. The seventeen that have a shape worth showing are served byCONFIG_DETAILS(§3.4), whoseemphasisprose is unit-neutral by design —LineOptionsbacks isobars in hPa and mixing ratios in g/kg as well as the temperature families, so a concrete example would have to be written once per family or be wrong for four of them (#124).Rejected (2026-08-11) — a matching reference page for
_constants.py. The options page publishes the attributes oftephpy.config, an object already in__all__; it does not publish a private module, so it sets no precedent for the 135#:-documented constants. Those are the conventions a configuration file exists to override, reachable by a user as thetephpy.configoptions that override them — not as names to import.Resolved (2026-08-12, PR #126) — domain validation of a value that has the right type. §5.2 checks a value against the type its field declares and stops there:
color: notacolouris a string, so it loaded, and matplotlib rejected it at the first draw. Answering it properly meant a per-option vocabulary — the colours, the edge names, the cursor fields, theemphasisstyle keys — which is why it was a larger piece of work than the type check it sits behind. Specified as domain spec §1–domain spec §7 and settled by that work (#116). This was the one entry here that was not a decision against, and the only one the docs spec §3.5 contract required an issue for.