Changelog#
Release versions follow Semantic Versioning, that is
<major>.<minor>.<patch>.
This page is the full record, one entry per pull request. For what is worth knowing about a release rather than everything that went into it, read What’s New.
v0.1.0.dev226 (2026-09-16)#
💣 Breaking Changes#
samples.path()now takes the name of the sample whose file you want —samples.path("norman-12z")— and raisesTypeErrorif called with no argument. It took none while the package shipped a single IGRA station file holding every sample; it now ships two formats (see the documentation entry below), and there is no file to return without being asked which. One file may still hold several samples, so two names can give the same path and differ only in the ascent selected from it.samples.available()reports the names, andsamples.sounding()is unchanged. (@claude) (#210)
✨ New Features#
Added the tephigram coordinate
transformsand a minimal"tephigram"Matplotlib projection, cross-validated against tephi. (@claude) (#9)Added the five zoom-aware background isopleth families with per-family accessor methods,
TephigramAxes.set_extent, thetephpy.configruntime configuration layer, and pytest-mpl image baselines. (@claude) (#15)Added the
Soundingdata model with ingest-time validation and pandas/xarray constructors, the pint units machinery over MetPy’s registry with the publictephpy.exceptionshierarchy, andTephigramAxes.plot_profile/plot_soundingwith multi-sounding overlays, derived legends, and profile image baselines. (@claude) (#19)Added the
tephpy.calcthermodynamic analysis layer —parcel_path()with surface and mixed-layer parcels and the operational cloud-base correction,normand_point(),indices(), and theProfile/SoundingIndicesdataclasses — together withTephigramAxes.plot_profileaccepting a parcelProfile, CAPE/CIN shading viashade_cape/shade_cin, theannotate_indicesside panel, and their image baselines; dropped the unconsumed scipy dependency declaration. (@claude) (#26)Added wind barbs and data ingest —
plot_barbs()drawing Met Office barbs (flag 50 kt, full 10 kt, half 5 kt, 5 kt binning) on a zoom-aware right-hand gutter staff, with the side panels sharing one divider so the gutter and the indices panel compose in either call order; thetephpy.ioreadersfetch()(University of Wyoming) andread()(IGRA v2) returning validatedSoundingobjects, with recorded, provenance-tracked fixtures;TephpyIOError; and the barb image baselines. (@claude) (#40)Added the
emphasisoption to every isopleth family, so a member can be drawn with a distinguishing style:isotherms()and its four siblings, and the matchingtephpy.configsections, map a member value tocolor,linewidth,linestyleandalphaoverrides. An emphasised member is always drawn, whatever the zoom ladder would select, soax.isotherms(emphasis={0.0: {}})marks the freezing level andemphasis={-12.0: {}, -18.0: {}}marks the dendritic growth zone that no isotherm interval lands on. (@claude) (#62)Added
add_logo(), which places the tephpy logo on a figure or an axes in one call. The position is relative to whatever it is called on — an axes anchors inside the plotting box, a figure against the figure edges — using thelegend()placement vocabulary, andsizeis a height in inches, soadd_logo(ax, loc="lower right")renders the same size at 100 dpi and at 600.theme="auto"picks the light or dark variant from the target’s background. The six brand masters ship inside the wheel, so it needs no docs tree and no network. (@claude) (#71)Added a YAML configuration file, so a tephigram house style no longer has to be retyped at the top of every script.
tephpy config generatewrites a fully-commented template of every option at its current default, andtephpy config pathreports which file is in force. tephpy reads the first match of$TEPHPYRC,./tephpyrc.yaml, and the file in your user configuration directory. See Configure tephpy From a File. (@claude) (#112)Added
tephpy.samples, two radiosonde ascents shipped in the package, and atephpy examplescommand that lists and runs the worked examples an installed tephpy carries.samples.sounding("norman-12z")reads one of the two Norman, Oklahoma ascents of 2013-05-20 throughtephpy.io.igra.read(), so a script no longer has to build a sounding before it can draw one, andtephpy examples run parcel-analysisdraws the figure the specification has described in prose since the project started. See Examples Gallery. (@bjlittle) (#181)ax.set_extent(...)now takes apressurerange and atemperaturerange as keywords, in place of two(pressure, temperature)corners, and frames the whole region it is given rather than the bounding box of the two corners named. The newax.fit(...)frames the view around soundings and parcel paths, withpressure=naming the layer of interest andmargin=defaulting todiagram.margin. Configuration files spell an extent as{pressure: [...], temperature: [...]}. (@bjlittle) (#194)wyoming.parseis public: theTEXT:CSVparser thatfetch()already used, exposed for a body the caller already holds — cached against a rate limit, pulled through a proxy this package’surlopencall cannot reach, or lifted from a bulk archive dump. The format is identical and only the retrieval differs, so without it every such caller writes a second reader that agrees with this one by inspection; sharing the parser is what makes a body read and a body fetched the sameSounding.stationandtimeare optional metadata rather than parsing input, the body carrying neither, and atimestring is coerced the wayfetchcoerces one so the two entry points cannot disagree about what a time string means. It does not make the archive reachable offline: parsing needs a body, and whether one may ship is #202. (@claude) (#203)Added a What’s New section to the reference quadrant, carrying the highlights of each release in prose beside the changelog’s record of every pull request. Each release’s page links that release’s changelog entry through a new per-release anchor, and the towncrier template now titles each release — it emitted no version headings at all before, so a second release would have run into the first. (@claude) (#322)
🚀 Enhancements#
Added Python 3.14 support per the SPEC 0 window; the default development environments now run the latest supported Python. (@claude) (#5)
Added a
minimum_separationkeyword toplot_barbs(), so a call picks its own barb thinning distance — an overlaid staff, or a longerlength=glyph, wants one wider than theBARB_MIN_SEPARATIONconvention. Passing it previously raisedTypeErrorfrom the staff constructor. (@claude) (#41)The interactive cursor readout (the matplotlib navigation toolbar’s coordinate text) over a tephigram now reports diagram-meaningful values —
850 hPa, -4.2 °C, θ 8.6 °C— instead of the raw rotated (x, y) data space.tephpy.config.cursor.fieldsselects and orders the readout from a five-field registry ("pressure","temperature","theta","mixing_ratio","theta_w"), and assigningformat_coord()remains the stock full-custom path. (@claude) (#48)The
labelsoption of every isopleth family accessor — for exampleisobars()— now places labels on the diagram’s edges as well as inline. AlongsideTrueandFalseit accepts the edge names"bottom","top","left"and"right", singly or as a tuple, soax.isobars(labels=("bottom", "left"))builds the printed chart’s pressure scale. Listed edges label the members that reach them and every member left over is labelled inline, so nothing goes missing when a family only partly reaches an edge. Edge labels are native matplotlib ticks and track pan, zoom andset_extent(). One family may hold an edge at a time; a second claimant raisesTypeError. Hiding a family — withvisible=Falseorset_visible()— releases the edge it held for another family to take, and showing it again reclaims it (raisingTypeErrorif another family took the edge meanwhile). The out-of-the-box diagram is unchanged. When an edge label set crowds (e.g. the default 50 hPa isobar family on the left edge), passinterval=to thin the family; theisobars()reference configuration usesinterval=150for a legible pressure scale. (@claude) (#51)edge_axis()returns the matplotlibAxisthat draws one diagram edge’s isopleth ticks, keyed by the same"bottom","top","left"and"right"names thelabelsoption takes. The top and right edges are drawn by lazily created secondary axes and had no public handle, so stock matplotlib styling could not reach them;ax.edge_axis("top").set_tick_params(labelsize=12)now works on all four edges alike. Releasing a top or right edge hides its secondary axes instead of destroying it, so a handle held across the release stays live and its ticks and title survive the reclaim. (#52, @claude) (#56)tephpy config generatenow wraps the line of prose above each option, so no line of the file it writes runs past 88 columns (#106). Eleven lines used to overrun, the longest reaching 117. Option lines themselves are left alone — a wrapped YAML value is not YAML — so what you uncomment and edit is unchanged. (@claude) (#123)The out-of-the-box tephigram now frames an ascent more closely. Its default view runs between the
(900 hPa, -65 °C)and(200 hPa, 5 °C)corners, where it used to run between(1050 hPa, -40 °C)and(200 hPa, 40 °C), so a sounding drawn withoutset_extent()sits centred instead of crowded into the left third of the diagram, and the blank corner where the view reached outside the temperature domain falls from 13.1% of the drawing area to 3.1%. Every isopleth family draws the same members it did before — the zoom ladder selects the same intervals at the narrower view — and a figure that sets its own extent, in code or through thediagram.extentconfiguration option, is unaffected. (@bjlittle) (#181)The 0 °C isotherm is now emphasised by default — drawn at
EMPHASIS_LINEWIDTHin the isotherm family’s own ink — and it is the only membertephpyemphasises out of the box (#80). Spec §3.2 had ruled that nothing would be emphasised, because every other tephpy default cites a printed-chart convention and this one could not: the evidence was operational software, and Factsheet 13’s published URL returned 404. The current edition is published, and beside the sentence giving the 10 °C isotherm interval thattephpyalready follows, it records that the 0 °C isotherm “is coloured red on the diagram”. The distinction is cited and the means is not — red is the temperature profile’s colour, and the factsheet gives none for the plotted ascent — so the member is distinguished by weight rather than by colour, which invents no convention. Passemphasis={}to any accessor to opt out.The citation gave the documentation its first one, so the bibliography exists now too.
sphinxcontrib-bibtexhad been loaded and configured since the foundation plan withrefs.bibseeded beside it, and in all that time nothing rendered a bibliography and no page wrote a:cite:— an installed extension doing nothing. AReferencespage in the reference quadrant lists the file, the emphasis how-to cites Factsheet 13 through it, the glossary cites the AMS Glossary of Meteorology entry that had sat unused, anddocs-stylegains the rule for adding a source: reproduce the published title, brace it so pybtex cannot lowercase it, and record the date it was consulted. (@claude) (#201)
🐛 Bug Fixes#
Fixed
matplotlib.figure.Figure.clear()raisingAttributeErroronce a diagram carried a side panel: the panel teardown now stands down for the figure’s own clear, which removes every axes itself. Hardened thetephpy.ioreaders on the same pass —fetch()maps thehttp.clientfailuresurlopen()leaves unwrapped (a truncated body, a malformed status line) and an unreadable HTTP error page toTephpyIOError, and rejects a body that is not CSV, a row shorter than the header, or a header with no data rows rather than raisingIndexErroror a misleadingTephpyValidationError;read()maps a corrupt zip (a bad CRC, central directory, or deflate stream) and says so plainly when an unmatchedtime=has no nominal launch times to suggest; and both readers now pass a one-sided wind column as absent wind, so the failure downstream isMissingDataErrorfromplot_barbs(). (@claude) (#41)Fixed the
shade_cape()andshade_cin()fills pulling away from the plotted profiles between sounding levels (#42): the region builders now sample both curves along the drawn polylines — the straight segments matplotlib draws between profile levels in tephigram (x, y) space — and pinch each region at the exact drawn-segment intersections, instead of interpolating temperature linearly in ln p, which bowed the fill away from the drawn lines mid-segment, visibly at larger figure scales. Geometry only: the annotated numbers still come frommetpy.calc.cape_cin(). (@claude) (#43)Hardened two
Soundingingest edges deferred from the Plan 4 review (#22): a bare field holding non-numeric values — for example an object-dtypefrom_dataframe()column carrying string missing-markers such as"-----"— now raisesTephpyValidationErrornaming the field instead of leaking a bareValueError, and anumpy.datetime64NaTlaunch time is rejected by an error that names NaT rather than reportinggot <class 'NoneType'>. (@claude) (#44)Isopleth member labels now honour a family’s
alphaoption, both inline and on a claimed diagram edge.alphais documented as applying to a family’s lines and its labels, but it reached only the lines, soisotherms()withalpha=0.4drew fully opaque labels over translucent isotherms. (@claude) (#51)Styling a claimed diagram edge’s ticks with
tick_params()now lasts. tephpy re-applied its own tick conventions on every isopleth family resolve, so an unrelated family —ax.isotherms(color="grey")afterax.isobars(labels="left")— silently reverted the tick label size, tick length and padding a user had set, and re-suppressed gridlines an explicitgrid()call had enabled. The conventions are now stamped once, when the edge axis is created, and an axis title cleared withax.set_ylabel("")likewise stays cleared for as long as the edge is labelled. (#52, @claude) (#56)Fixed an isopleth family drawing a stale geometry after a
tephpy.configchange (#63):configure()decided whether to rebuild its cached members from the keyword names it was handed, so avalues,interval,truncationoremphasisset ontephpy.configwas reported by the family’s resolved options yet never drawn. It now compares the resolved geometry, which also spares a rebuild when a caller re-passes a value the family already has. (@claude) (#62)Fixed an isopleth family holding on to label artists it no longer draws (#54):
IsoplethFamilypools oneTextper inline label and grew that pool to fit each draw, but never gave the surplus back. Anything that shrinks the labelled set — zooming back out after a finer ladder step, or claiming an edge, which hands the members reaching it to the axis ticks — left the high-water mark pinned for the life of the axes; a round trip through the default view’s zoom ladder retained 40 labels that would never be drawn again. Switching labelling off, or hiding the family, stranded the whole pool the same way. The pool is now trimmed to what each draw actually labels, and released outright when a draw labels nothing. (@claude) (#67)Fixed the configuration-file warnings blaming a file inside
tephpyrather than the code that triggered them (#107). An unknown option, a null option value, and an unreadable configuration file each reported a tephpy frame the reader cannot act on; they now name your owntephpy.config.loadcall, or theimport tephpythat ran the auto-load. Code suppressing these by module —filterwarnings(..., module="tephpy")— no longer matches as a result, and should filter on theTephpyConfigWarningcategory instead, which is unaffected. (@claude) (#113)Fixed a configuration file value of the wrong type being applied unchecked (#105).
linewidth: thickloaded silently and failed much later inside Matplotlib, naming neither the file nor the option;linewidth: trueandvisible: maybeloaded and drew the wrong thing without any error at all; andextent: 5did the opposite, rejecting the whole file over one line. Every one of these now warns, naming the file and the option, and skips just that option — the rest of the file still applies. An integer is still accepted wherever a number is wanted, solinewidth: 1is unaffected. A quoted number is not:values: ['1000', '850']and anemphasiskey of'850'used to be read as numbers, and a number infieldsused to be read as a string; all three now warn and are skipped. An integer of 309 or more digits has no number to convert to, and used to stopimport tephpyoutright; it now warns and is skipped like any other. The two errors that do reject a whole file — an unknown section, and a section that is not a mapping — now name the file too. (@claude) (#115)Fixed a configuration file value being applied without any check that its option can accept it (#116).
color: notacolouris a string andinterval: 0is a number, so both passed the type check added in #105 and loaded in silence; matplotlib or tephpy then rejected them at the first draw, in a traceback naming neither the file nor the line you edited. Some did not even do that —linewidth: -1.0,linewidth: .inf,values: [0, .nan]andtruncation: .nandrew a diagram that was simply not the one the file asked for. Every one of these now warns as the file is read, naming the file, the option and what the option can accept, and skips just that option — the rest of the file still applies. Where the legal set is closed, the warning lists it.color: b0b0b0is told it is probably missing a#, the mirror image of thecolor: #b0b0b0trap that YAML reads as a comment. A compound option is skipped whole: one bad member of anemphasismapping costs the wholeemphasisoption. One value that used to work is now refused:linewidth: 0is how matplotlib is told to draw no line, andvisible: falseis the option that means it. Values set through Python — assigning totephpy.config.isotherms.colordirectly — are unaffected, and still fail at the draw as before. (@claude) (#126)Fixed inline isopleth labels rendering as pale blobs over the diagram under a dark matplotlib style (#173). Each label’s box was filled with a hard-coded white, a bet that the canvas is white, so under
plt.style.context("dark_background")every label masked the lines it was labelling instead of dimming them, and the tephigram became unreadable. The box now takes its colour from the canvas it sits on — the axes facecolor and every figure behind it, alpha-composited over an assumed white page, which is the same readingadd_logo()already thresholds to choose a light or dark brand master, now shared so the two cannot disagree about what colour the background is. Every figure, because aSubFigurepaints nothing by default: an axes in one sits over the root figure through two invisible sheets, and reading only its direct parent would answer white for a canvas the reader sees as black. The colour is resolved on every draw rather than once where the label is created, because labels are pooled and outlive the background: a diagram restyled after its first draw would otherwise keep the colour the canvas used to be. There is no new option to set — the box tracks the canvas and is not configurable — and no rendering changes on a default white canvas, where the composite is still exactly white. (@claude) (#180)Fixed
calc.parcel_path(...)andcalc.indices(...)raisingDimensionalityErrorfor aSoundingwhose pressure was ininHgormmHg(#214). Threedry_lapsecall sites rebuilt the pressure array in hPa while passing a reference pressure still in the sounding’s own unit. The analysis path is now pinned by a parametrised test over ten pressure units that requires the answers to agree, not merely the calls to succeed. (@claude) (#219)Every gallery thumbnail’s hover tooltip showed its example’s summary with the cross-reference markup mangled: two leaked raw
<target>text at the reader, and three silently published the link target in place of the words around it —Two soundings from the same stationread asTwo sounding from the same station. sphinx-gallery lifts that paragraph out of the docstring with regular expressions rather than by parsing it, and the explicit-title role form does not survive the trip. The five summaries now use the bare form, so the tooltips read as written and the terms stay linked on the page. (@claude) (#258)Made each release a section of the changelog rather than a second page title. The towncrier template underlined a release with towncrier’s
top_underline, which is=— the same style the changelog page uses for its own title — so docutils read it as a sibling top-level section and the reference quadrant’s toctree listed the newest release beside Changelog as though it were a page of its own (#322). It now underlines with-, which nests correctly and leaves the category headings a level below it again. (@claude) (#323)
🔗 Dependencies#
Raised the matplotlib floor to
>=3.10. tephpy’s zoom-aware artists pass therootkeyword toget_figure(), which matplotlib added in 3.10, so the declared>=3.9could not draw a labelled diagram at its own floor. 3.10 is also the oldest matplotlib inside the SPEC 0 window. (@claude) (#41)Added
click,platformdirsandpyyamlto tephpy’s core dependencies, andsphinx-clickto the documentation extras. The three runtime additions carry the configuration file and thetephpy configcommand.pyyamlis floored at>=6.0.1rather than>=6.0: the6.0sdist has no cp312 wheel and fails to build under current setuptools/Cython, so it cannot install on tephpy’s supported Python versions. (@claude) (#112)The
sphinx-click >=6.0floor declared for the documentation extras is now verified rather than assumed (#109). It was taken from the package’s changelog when the extras gained it, unlike the three core floors declared alongside it, which were each resolved by hand — and nothing tephpy runs resolves a declared minimum, since every pixi task passes--frozen, so a floor set too low fails for one person only, the user who happens to resolve that version. Resolved now against the rest of the documentation dependencies,6.0.0builds the documentation clean, and the floor stands: the sole difference from the version the lockfile pins is that6.2.0labels each command’s usage block on the command-line reference page with aUsageheading. (@claude) (#133)The declared floors for
matplotlib,metpyandpintare raised to minimums that hold, from>=3.10,>=1.6and>=0.24to>=3.11,>=1.7and>=0.24.4. Each was a version tephpy could not run at.LineCollectiononly accepts a nested'none'fromset_linestyle— the oracle the configuration domain validation asks — from3.11.0(#135), andsaturation_mixing_ratio()only returns a non-finite value where the quantity is undefined, which the supersaturated cursor readout relies on to omit the field, from1.7.0(#137). pint is raised further than the conda channel alone requires: every release before0.24.4declaresflexparser>=0.3uncapped on PyPI, so a lowest-direct resolution pairs it with a flexparser whose non-frozen dataclasses pint cannot inherit from, and the import chain dies before any test runs (#136). All three were found by the weeklyci-floorsjob on its first live run — the first thing tephpy runs that resolves a declared minimum rather than the lockfile. (@claude) (#147)The declared floors for
sphinx-designandsphinx-autoapiare raised to minimums that hold, from>=0.6and>=3.3to>=0.6.1and>=3.6.1(#138).sphinx-design 0.6.0requiressphinx >=5,<8, which thesphinx >=8.0declared beside it contradicts, so the documentation tier never resolved at its floors; and everysphinx-autoapiup to and including3.6.0declaresastroid >=3uncapped, so a resolution at the floors pairs it with astroid 4, which it cannot drive — it reports every module of the package unreadable, emits no API objects, and the build then fails under--fail-on-warningon the cross-references to pages that were never written. Both were found by the weeklyci-floorsjob, the last of the three tiers it reported on its first live run. (@claude) (#148)The
testtier now declares a build backend that works at its floors, which the wheel the test suite builds needs and neither declaration site supplied (#152).packagingis raised from>=24.0to>=24.2, the first release carryingpackaging.licenses: without it thesetuptools >=77.0.3declared beside it raisesImportErrorinstead of canonicalizing the SPDXlicenseexpression of this project, and the build ends before it starts. On the PyPI halfsetuptoolsandsetuptools_scmwere never declared at all, so a resolution at the floors left no backend installed to build with; they join thetestextra rather than the core requirements, because neither is a runtime requirement of the wheel. Both were found by the weeklyci-floorsjob, which could not attribute either — the test that fails skips itself in the diagnosis probe, a blind spot now tracked separately (#154). (@claude) (#153)
📚 Documentation#
Added the developer documentation-style guide (title style and glossary rules). (@claude) (#1)
Documented the changelog author attribution convention using the
:user:extlink role. (@claude) (#12)Documented the changelog-fragment convention of cross-referencing documented APIs with Sphinx roles instead of quoting the names, and applied it to the existing fragments. (@claude) (#27)
Glossary definitions now cross-reference the tephpy APIs they name — for example
Sounding,parcel_path(), andshade_cape— straight into the API documentation, and the documentation style guide records the convention. (@claude) (#31)Third-party APIs in the documentation now cross-reference into their upstream docs through intersphinx — for example
metpy.calc.moist_lapse(),pint.Quantity,pandas.DataFrame, andxarray.Dataset— and every numpydoc parameter and return type links automatically, with the convention recorded in the documentation style guide. (@claude) (#32)The documentation is now built with Sphinx’s
nitpickymode enabled, so any unresolved cross-reference fails the build instead of silently rendering as plain text. Enabling it caught and fixed references that were not linking — tephpy’s own exceptions such asTephpyUnitsErrorinRaisessections, andIsoplethFamilyin the tephigram axes accessors — which now resolve to their API documentation. (@claude) (#33)Migrated
readthedocsbuilding infrastructure to use theirpixiplugin. (@bjlittle) (#36)Documented the attribute-documentation convention for the API reference in the developer style guide. Because
sphinx-autoapiparses the source statically, Sphinx#:doc-comments are dropped from the rendered page, so a documented attribute uses the numpydocAttributessection or a PEP 224 attribute docstring (a triple-quoted string below the assignment) instead. (@claude) (#37)Swapped the design spec’s canonical usage example — and the station/time identity echoed through the
Soundingandfetch()docstrings, label examples, and tests — from a stable Camborne profile whose 4 J/kg of CAPE renderedshade_cape()as a sub-pixel sliver, to Norman, Oklahoma at 2013-05-20 12Z (the Moore EF5 morning sounding: 1810 J/kg of CAPE, −271 J/kg of CIN), so the canonical example visibly demonstrates every call it makes — now includingshade_cin(). (@claude) (#45)Fixed the project banner not rendering on PyPI: the
<picture>sources inREADME.mdwere repository-relative paths, which resolve on GitHub but 404 once PyPI serves the same markup frompypi.org, so they are now absoluteraw.githubusercontent.comURLs. Addeddocs/src/_static/brand/assets/README.mdmapping every published brand file to its counterpart inlogo-bundle.zip, recording which are consumed byconf.pyand the project README, and naming what the bundle carries that tephpy does not ship. Kept that directory out of the built site withexclude_patterns, whose entries are matched relative to thehtml_static_pathroot for static-file copying. (@claude) (#69)Published tephpy’s design specifications in the developer guide (#65). The
spec §…citations throughoutsrc/andtests/now resolve to a section a reader can reach: every numbered section carries a stable anchor keyed to its number, rather than the slug docutils derives from the heading text — which discarded the number and silently collided where two sections shared a title. The implementation plans stay tracked in the repository but are deliberately unpublished. Every unresolved item in the roadmap now carries a status tag and a tracked issue. (@claude) (#83)Brought every published design specification under the status contract, not just the parent one. The contract — each unsettled item carries a status tag and points at a tracked issue — was written around the parent specification’s “Assumptions and open decisions” and “Open questions” sections, which the
add_logo()and published-specifications documents do not have, so both sat outside it while carrying untracked work. A specification with neither section now records its open items in its scope section instead, to the same rule. (@claude) (#87)Design specification citations such as
spec §3.2now render as links to the section they name (#85), throughout the documentation and the API reference. The citations themselves are unchanged: a Sphinx transform resolves them while the doctree is built, so nothing undersrc/was edited and the written form is still the plain text it always was. A companion check asserts that every rendered citation became a link — the converse of the existing citation-integrity hook — and the style guide records the placement rule that follows from it: cite a section in body prose. (@claude) (#90)The check that every rendered specification citation became a link now reports the unlinked ones by where they sat — a page title, navigation chrome, an API signature or body text — and gives the advice that follows from each placement, rather than one message written for all of them. Citations nested inside a link are explained too, having previously been counted and left unexplained. A toctree
:caption:is recorded as a fourth placement that cannot carry a citation. (@claude) (#97)The glossary now defines parcel ascent, the process the landing page has always named, and the README links its thermodynamic terms — tephigram, parcel ascent, CAPE, CIN, LCL, LFC and EL — to those definitions, so the repository front page is a second way into the documentation. The documentation build checks those links resolve, which nothing else could: the README is not one of its sources. (@claude) (#99)
Every reference to a tephpy issue or pull request in the design specifications is now a link the reader can follow: 59 of them were plain text or a hand-written URL, and each is written with the
:issue:or:pull:extlink role instead, so the URL is stated once in the documentation configuration. A pre-commit gate and Sphinx’s ownextlinks_detect_hardcoded_linkskeep it that way. The pull-request number towncrier appends to every changelog entry is now a link too. (@claude) (#103)Added the design specification for tephpy configuration files, published in the developer guide under the
configfile spec §…citation prefix. It specifies a YAML file discovered by amatplotlibrc-style first-hit-wins cascade and loaded at import as a persistence tier beneathtephpy.config, so a house style no longer has to be retyped at the top of every script; atephpy configconsole script that writes a fully-commented template of every option at its current default, and reports which file is in force; and the gates that keep the template’s defaults from drifting away from the ones the plotting code actually applies. The specification is not yet implemented. (@claude) (#104)Added a Code Examples section to the documentation style guide, stating what an author may rely on across the blocks of a page, and replaced the
# noqacomment in two how-to snippets with the reason the import is there. (#114, @claude) (#118)tephpy.confignow has an options reference page, Configuration Options, listing every option with the type it accepts and the default in force, and giving each one a target that documentation and docstrings can link to —tephpy.config.isotherms.emphasisand the rest, alongsidetephpy.config.load()and its three companions (#108). The page and the filetephpy config generatewrites are two renderings of one table, so they cannot come to describe different options. (@claude) (#123)Gave every method on the configuration options reference page a worked example (#124). A signature and a sentence say what
tephpy.config.context()is; they do not say that its keyword arguments are section names mapped to{option: value}mappings. That method andtephpy.config.reset()act on the configuration already in memory, so the configuration how-to covers neither, and the reference page was the only place in the documentation either one appeared at all. The examples are executed by the test suite, against the live API rather than a transcript of it. (@claude) (#125)Configure tephpy From a File now covers a value of the right type that its option still cannot accept, and Configuration Options lists the closed sets of legal values — the diagram edges for
labels, the style keys foremphasis, and the cursor readout fields — from the same objects the loader checks against, so the page cannot document a value the loader rejects (#116). (@claude) (#126)Configuration Options now shows the values a reader has to type as inline literals — the diagram edges for
labels, the readout fields forcursor.fields, the opacity bounds foralpha, and the corner shape fordiagram.extent— rather than as prose they blend into. The filetephpy config generatewrites is unchanged, since the same descriptions have to read there as plain-text YAML comments (#127). (@claude) (#129)The published-specifications design no longer records counts that go stale (#94). Every figure it measured from content a pull request can change — how many citations name each specification, how many links the build renders, how many the transform leaves plain — was true when written, and nothing reported that it had turned, in a document whose readers are invited to trust it. Each is replaced by the invariant behind it and the way to obtain the number, leaving the counting to the gates, which are current by construction; the figures that stay record what was measured when a decision was taken, and say so. docs spec §4 states the rule, so a figure written later is judged by it rather than by this change. The documentation style guide now also warns against citing a section in a heading, which the build reports even where the author wrote the link themselves (#96). (@claude) (#132)
Added the design specification for a scheduled job that resolves tephpy’s declared dependency floors, published in the developer guide under the
floors spec §…citation prefix. Because every task passes--frozen, no check in continuous integration has ever resolved a declared minimum, so the floors inpyproject.tomlandrequirements/are assertions nothing tests (#109). The specification covers the two declaration sites, the generator that turns each declared floor into a pin, what each tier runs once pinned there, attributing a failure to a single package by relaxing it, the upward scan for the lowest version that passes, and the issue the job files. The specification is not yet implemented. Resolving the floors by hand while writing it found four defects the job will report on its first run (#135, #136, #137, #138). (@claude) (#139)Added an experimental browser tutorial that lazily runs the current tephpy wheel entirely client-side, plots its bundled sounding on an interactive matplotlib canvas, accepts local CSV uploads, and exposes the normalized plotted values in a collapsible table. The tutorial explains each matplotlib navigation control and links to the browser-runtime projects the demo depends on. (@openai) (#143)
Three counted claims in the published design specifications were corrected against the code they describe. The dependency floors specification said thirteen of the
testtier’s tests guard on a repository being there, which became fourteen in #164; the parent specification gave thedocsjob a 30-minute bound, which #165 raised to 35. That passage now also records why neither retried attempt may escalate privilege, the constraint #166 settled. The floors specification additionally stated the rule that no test reads the manifest the job rewrites without recording that #164 gates it, leaving a checked property reading as a convention. (@claude) (#167)The Emphasise a Reference Isopleth and Add the tephpy Logo how-to guides now show the figures their snippets produce, rendered from those snippets by the documentation build rather than described in prose. Every python block on a page that publishes figures is a
.. plot::, so the block a reader is invited to copy is the one that drew the picture above it. The rules a page follows to do that are written down in the “Published Figures” section of the documentation style guide. (@claude) (#172)The published-figures design specification now records why the “Light and Dark” section of the Add the tephpy Logo how-to guide shows no picture. Its scope section gains a
Blockedopen item citing #173: the inline isopleth labels are styled for a white canvas, so a tephigram drawn under matplotlib’sdark_backgroundstyle is covered by the labels that annotate it. The block still runs, andadd_logo()still draws the dark-background variant the surrounding prose describes. (@claude) (#176)Added the Examples Gallery, five worked examples of what tephpy draws, built with
sphinx-galleryfrom the modules the package ships. Each page shows the figure and its source, offers the script and a notebook to download, and carries tags the gallery index filters on. The example set covers the bare diagram, a sounding with wind barbs, the parcel analysis with its CAPE and CIN shading, two ascents overlaid, and MetPy’s hodograph inset on a tephigram. Their prose cross-references the glossary, which gains the four entries they reach for and it did not define: projection, cap, special sounding and hodograph. (@bjlittle) (#181)Stated the project’s scope and support in the places a reader looks for them: the six non-goals of the design specification now appear in
README.mdwith somewhere to go instead, a new how-to shows how to decode BUFR with ecCodes and build aSoundingfrom the result, a new developer packaging guide carries the SPEC 0 support window and the dependency-floor policy, and the glossary gained its lapse rate entry. The how-to also says where a TEMP bulletin leaves you, ecCodes decoding no traditional code form and there being no maintained converter to point at. (@bjlittle) (#191)The developer-docs review checklist that spec §8.6 has claimed since the design specification was written now exists (#193).
docs-style.rstgains a Reviewing Claims section carrying the three questions that stand in for the gates this class of defect escapes: which member of a set did you check, does the page title survive that question, and what tool did you actually run. Each is phrased to have a name for an answer, because “did you verify this?” does not. The section widens the rule that a snippet’s behavioural promise is pinned by a test to cover the promises no test can pin, and makes the provenance of a claim about an external tool — its version and what the run did not establish — belong in the living specification rather than only in the frozen plan. Both spec §8.6 and scope spec §5 stop citing a checklist that did not exist; the README’s non-goals lead sentence stops saying each where spec §9 says most; and the pull request template points a reviewer at the checklist, over a linkcheck_documentation_links.pynow resolves. (@claude) (#195)The tutorial quadrant is reStructuredText, like the other three, and a test now holds it there. Spec §8.6 had called the tutorials myst-nb notebooks since before any of the machinery that would have to read them existed, while plots spec §3.1 rests its choice of
plot_directiveon the user quadrants being reStructuredText and docs spec §3.9 scopes the snippet gate to.rstfor the same reason — so Plan 7c would have delivered the one quadrant written for a learner as the one quadrant with nothing executing it. Spec §8.6 is corrected to match the two sections that already said so. The.rstboundary docs spec §3.9 describes in prose is now enforced bytest_no_user_page_is_written_in_a_format_this_gate_cannot_read, which fails on any.mdor.ipynbunder the quadrants rather than letting the page build, publish and skip the gate in silence; andnb_execution_modeis recorded with its reason, having been set inconf.pyand stated in no specification. (@claude) (#199)The design specification for Plan 7c — the narrative quadrants — is published (#66). It settles the shape of the last plan before release execution: two tutorials, each paired with the one explanation page that answers the question it raises; a reader how-to for the supported routes into a
Sounding; and the glossary sweep around all five. It records that both supported routes into a sounding are demonstrated as running code, the recorded Wyoming ascent shipping alongside the IGRA one so that neither half of the page is prose standing in for a demonstration. It also closes two questions this plan inherited rather than deferring them again — no doctestedExamplessections (#189), most of the public surface returning artists that a doctest would assert nothing about, and no site-wide tag index, an eleven-page narrative corpus not being one that needs filtering. (@claude) (#204)The explanation quadrant exists. It had been a placeholder promising that background would appear as the package grew, and it now carries the two pages spec §8.6 asked for (#66, narrative spec §3.4 and narrative spec §3.5).
Why the Axes Are Rotated answers the question a tephigram provokes on sight, in the order the construction goes: temperature against entropy, because area is then energy;
ln θstanding in for entropy; isotherms and dry adiabats straight and exactly perpendicular, which is what the coordinates were chosen to produce; the forty-five degree rotation, which preserves that right angle and puts the ground at the foot of the page; and pressure as a derived quantity, which is why isobars curve and why there is no vertical axis to read against. Met Office Factsheet 13 is cited for the printed chart’s conventions, through the bibliography.Parcel Ascent and Normand’s Point follows the parcel rather than the API, and closes the loop the first page opens: the buoyancy integral between a parcel and its environment is an area on these axes because of the coordinate choice, which is why
shade_cape()shades a region rather than annotating a number. It also names two things that were implicit — thattephpydraws while MetPy computes, and that the operational −25 mb cloud-base shift is a convention applied only when asked. (@claude) (#205)The tutorial quadrant carries a learning path. It had one page, a browser demo with no python in it, and now opens with two tutorials a newcomer can follow start to finish (#66, narrative spec §3.2 and narrative spec §3.3).
Your First Tephigram assumes no meteorology: an empty diagram, what the five isopleth families are, a shipped radiosonde ascent drawn on it, and how to read the temperature and dewpoint traces apart. It closes on the freezing level, which is already marked — the 0 °C isotherm has been drawn heavier since the reader’s first figure, so naming it costs no code.
Analyse a Sounding continues from the same ascent and asks whether that atmosphere could produce a storm: a parcel lifted from the surface, the bend at cloud base, the two energies shaded as the areas they are, and
annotate_indices()putting ten computed values beside the diagram. It hands over to the gallery, where the same analysis appears finished. (@claude) (#208)A how-to covers getting data in, and demonstrates both routes rather than one (#66, narrative spec §3.6). Read a Sounding From an Archive reads the same balloon flight twice — through
read()and through theparse()exposed by #203 — and draws both on one diagram, where they cannot be told apart. That is the page’s subject: two archives, two readers, oneSoundingdownstream.Showing it needed the same physical ascent in both formats, so
tephpy.samplesnow ships the Camborne 2026-07-21 12Z pair beside the two Norman ascents. Every sample still reads through a public reader — a sample reached by a private path would demonstrate nothing a reader could repeat. Carrying a second format changedsamples.path, which the breaking-change entry above records.The page also states what it does not run.
fetch()is described rather than shown as a block, because every python block in these guides is executed by the test suite on every supported Python version — and again by the documentation build where it publishes a figure — so one reaching the University of Wyoming would fail for reasons that have nothing to do withtephpy. Little is lost in the reading:fetchisparsewith a download in front, so a fetched sounding and a parsed one are built by identical code.Each Diátaxis landing page now introduces its quadrant. Two of the four had no prose at all and a third had four words, so a reader arriving at one met a bare list of links and had to infer what kind of material it held and whether it was addressed to them. Each now says who it is for, what is in it, and where to go instead — the tutorials pointing a reader who is not new to the how-tos, the how-tos pointing one who is to the tutorials. A line beneath the landing page’s four cards names the Diátaxis framework they follow and links it, for a reader curious why the documentation is divided that way rather than by subject. (@claude) (#210)
Added Build a Sounding From Your Own Data, a how-to for the
Sounding.from_dataframeandSounding.from_datasetconstructors, which the user documentation had never shown despite pandas and xarray both being hard runtime dependencies (#212). Records the asymmetry between them: a DataFrame carries no units, sounits=is required, while a Dataset is read through each variable’sattrs["units"]andunits=is the override. Also documentssamples.available()on Read a Sounding From an Archive. (@claude) (#215)Added Work With Units, documenting the units policy of spec §5: pint quantities are accepted directly, any pint-parseable unit string is accepted and the field stored in that unit rather than converted, and every field comes back as a
pint.Quantityon MetPy’s registry (#212). Also notes thatcalc.parcel_path(...)does not yet work for every accepted pressure unit — raisingDimensionalityErrorforinHgandmmHg— atephpybug tracked as #214. (@claude) (#216)Corrected the title-style rule in the documentation style guide, which specified Chicago Manual of Style headline style while the corpus followed a length threshold — capitalising a medial preposition of four letters or more and lowercasing shorter ones (#217). The rule now states that, and names its deviation from CMOS rather than claiming conformance. Retitled Decode BUFR With ecCodes to “Decode BUFR With ecCodes”, the one title that did not already follow it. (@claude) (#218)
Added Label and Compose the Diagram, covering the
labels=edge option andax.edge_axis(...), setting a tephigram beside another axes without it shrinking in its slot, and vector output for publication (#212). A fresh diagram carries no axis titles because no isopleth family has claimed an edge, and claiming one supplies its title — neither of which the documentation had said. (@claude) (#220)Documented the two isopleth families no user page had drawn, on Emphasise a Reference Isopleth, together with the two places the family accessors stop being interchangeable:
mixing_ratiostakes nointervalandmoist_adiabatstakes atruncation(#212). Addedtephpy.config.sourceandtephpy.config.reset()to Configure tephpy From a File, and said in Analyse a Sounding thatcalc.indices(...)returns a readableSoundingIndicesrather than only a panel. (@claude) (#221)Pointed readers at
tephpy.exceptions, whose module docstring already sets out the exception hierarchy and whatexcept TephpyErrorcatches, from the reference index and from each user page that names an error (#213). The hierarchy was documented and published; nothing led a caller to it. (@claude) (#222)Swept the public API for the numpydoc
Raisessection, which nothing enforces — numpydoc’s validation has noRScheck, so a missing or stale entry is invisible topixi run lint. Construction is where this bit hardest: the three public dataclasses validate their arguments and documented none of it, soSounding,ProfileandSoundingIndicesnow carry aRaisessection, in the class docstring where a reader meets it (__post_init__is not rendered).calc.parcel_pathandcalc.indicesnow document theDewpointExceedsTemperatureErrora"mixed-layer"parcel can reach:metpy.calc.mixed_parcelaverages potential temperature and mixing ratio independently, so a saturated layer can yield a mixed dewpoint above its mixed temperature — the surface caseSoundingalready rejects.wyoming.parsenow documents thetime=TypeErrorandValueErrorits siblingswyoming.fetchandigra.readalready did,plot_profiletheTephpyValidationErrora non-numeric array raises, andIsoplethFamily.set_visibletheValueErrorand the furtherTypeErrorroutes it inherits from beingconfigure(), which re-reads every configuration tier — so atephpy.configvalue gone bad since the family was built raises on hiding as readily as on showing, not only on the edge-ownership conflict the entry named. (@claude) (#223)The gallery pages no longer show the
# sphinx_gallery_tags = [...]flag in the example they publish.remove_config_comments = Trueinsphinx_gallery_confstrips# sphinx_gallery_*lines from the rendered code afterpy_source_parser.extract_file_confighas read them off the source, so the tags still render on each page and still drive the index filter, and the reader of a page whose purpose is showing source is shown the example rather than the instruction that published it.Gallery spec §3.6 had decided the other way, and the decision is reversed rather than contradicted: it was taken against
sphinx_gallery_start_ignore, which would have hidden the flag by putting three lines of machinery into the file the reader downloads and runs, and which hides whatever it is wrapped around.remove_config_commentspays neither cost — the example files are untouched, the downloaded.pystill carries the flag, and the pattern it strips matches sphinx-gallery’s own flags and no other comment.Measured rather than assumed: the gallery was rebuilt with the option off and on, and the two generated trees diffed. Across the five examples the only content that changed was the five flag lines and the source-line spans that follow from them, the five pages still render their
Tags:line, and the index still carries fivedata-sgtagsthumbnails behind the filter. The one thing the removal leaves is the blank line the flag stood on: the rendered block shows three where PEP 8 wrote two. (@claude) (#236)The gallery pages now show the two blank lines PEP 8 wrote between an example’s imports and its
def main, where #236 left three.remove_config_commentspreserves the blank lines around the flag it strips, so the flag’s own blank line survives it. That made the placement of# sphinx_gallery_tagsload-bearing for the render rather than a matter of taste: written with a blank line above it, the flag leaves that line on the published page. The five examples now carry it flush under their imports, and the rendered block readsFigure, blank, blank,def main— measured off the built pages, all five.tests/examplesasserts it, because nothing else could. A blank line raises no Sphinx warning, so--fail-on-warninghas nothing to catch; the removal is silent by design; and the next example is written by copying the one beside it, which is how the shape would have spread.What the assertion reads is the spacing, not the placement. It strips the flags with sphinx-gallery’s own
INFILE_CONFIG_PATTERN– transcribed for the reason the tags are read from source text, that thetest-py3*environments have no documentation dependencies – and counts the blank lines the page would then show. Asserting where the flag sits was the first attempt and was a proxy for the invariant: a secondsphinx_gallery_*flag added below the tags, with a blank line of its own, satisfied it while the page went back to three blank lines. Both that shape and a blank line above the flag were forced, and fail naming the example and the count. Gallery spec §3.6, gallery spec §3.7 and the documentation style guide record the rule rather than leaving it to the diff. (@claude) (#237)Documentation pages now open with an estimated reading time. A custom
readingtimeSphinx directive counts the words in the parsed page — so directive options, comments and front matter are not counted and a generated page body is — and divides by 150 words per minute, a rate below the floor Brysbaert (2019) reports for ordinary non-fiction prose. A page may quote a literal duration instead, or override the rate for itself. A gate keeps the banner on all 29 published pages a reader reads start to finish, against a named list of twelve exemptions for the pages that are navigated instead. (@claude) (#240)Reconciled
reading spec §6with the tests it inventories. Two assertions were reachable from neither its prose nor its named list: the one holding the banner to “1 minute” rather than “1 minutes”, and the one holdingWPMto the 150 thatreading spec §3.4argues for. The first had been described under the wrong file, where the module it names has no plural logic to test. (@claude) (#241)The citation gate now asserts that no line break separates a citation’s prefix from its section number. A prefix ending a line never reaches a section sign opening the next, so the citation resolves against whatever prefix shares its line — the anchor exists, the existing rules pass, and the page links to a section nobody wrote. The new rule asks whether undoing the wrap moves the anchor, so a citation that reads the same either way is never reported. One live instance was found and fixed:
narrative spec §3.6in the gallery specification had wrapped afternarrative, and linked to the parent specification’s browser-demo section instead. (@claude) (#243)Reviewing Claims asks a fourth question: does the prose say how a function arrives at its answer, and was that read? A sentence about what a function does is checked by reading it once; a sentence about how it gets there is a claim about an implementation that can change underneath the page. Both defects #206 records are of the second kind, and no gate reaches them — a false sentence about a function executes nothing, and the function it names resolves. The question is narrow by measurement rather than by hope: across the user pages it bears on four of the twenty-five sentences that name a function, where “did you read it?” would have borne on all twenty-five. (@claude) (#249)
The ecCodes recipe’s units claim now has recorded provenance, and is narrower for it. The page said ecCodes “reports temperatures in kelvin and pressures in pascals” — load-bearing, since a reader who declares
degCwhere the decoder handed them kelvin is wrong by 273 K with no error — and nothing anywhere recorded a run or a table behind it (#196). Stated of ecCodes it was also too broad:dewpointTemperaturehas a descriptor defined in degrees Celsius, andwindSpeedhas knot and kilometres-per-hour variants. What makes it true is the message rather than the tool, so the page now says what a radiosonde message carries.scope spec §3.2records the descriptors, the table version, the tool version and the run behind that, including the output the recipe was written without. (@claude) (#250)Added hover tooltips across the documentation, drawn by sphinx-tippy. Every glossary term and every API cross-reference now shows its definition on hover, without leaving the page, including every term of a glossary entry that defines several at once and every signature of an overloaded API method – a vendored correction for an upstream defect that otherwise left all but one member of such a group bare. The tooltip runtime is vendored rather than fetched from a CDN, so the tooltips work offline and no page reaches a third party. (@claude) (#251)
The landing page’s four Diátaxis cards now carry themed icons. Each is drawn in the brand mark’s own vocabulary — the 45° lattice, a profile, an accent on the thing being pointed at — rather than taken from a third-party icon set, and each shows its quadrant’s subject: a path walked to a marked end, one isopleth of a family drawn heavier, the axes turning through 45°, an index with one entry found. The artwork is hand-authored SVG under
docs/src/_static/cards/, a light and a dark file per quadrant so the pair can use the brand’s two-tone palette rather than one colour compromised to survive both grounds. The icon sits beside the description where there is room for it and above the description where there is not, because.. grid:: 2is two columns at every breakpoint. The grid also gains a gutter and the cards a wider corner radius, so that four links read as four objects rather than as one block ruled into quarters. (@claude) (#260)A design specification for topic discovery across the Diátaxis quadrants,
topics spec §…. Diátaxis segregates documentation by the reader’s intent, but a reader often arrives with a topic, which lands in several quadrants at once — measured on the corpus of 2026-09-03,soundingappears in twelve of nineteen items across three quadrants, and nothing on the site says so. The specification designs a closed seventeen-term vocabulary generalising the gallery’s own — each term defined by what it covers and what it excludes, because the boundary is the part that stops two contributors tagging the same page differently — a promotion rule deciding which terms earn a filter button, one published page listing every item with its quadrant labelled, a gate, and a monthly coverage report. It reopens narrative spec §3.8, which rejected a site-wide tag index, on the grounds that the eleven-page premise has moved to fourteen and that the question answered there was about volume rather than about the axis the corpus is organised on. (@claude) (#262)Added a topic index at Browse by Topic, listing every tutorial, how-to, explanation page and gallery example with the quadrant it belongs to, filterable by topic and by quadrant. Diátaxis sorts the documentation by the reader’s intent; this sorts it by subject, which is orthogonal to intent and lands in two, three or four quadrants at once. The seventeen-term vocabulary is shared with the gallery’s own tags, and a term earns a filter button by spanning two or more quadrants while selecting fewer than half the corpus. Quadrants carry a colour of their own, on both the button and each row’s badge, so a filtered list shows at a glance which quadrant answered. (@claude) (#264)
The monthly topic-coverage report now totals each quadrant’s column — how many of the vocabulary’s terms a quadrant covers at all — and names the terms that select half the corpus or more, with their share and the quadrants they hold. A term that broad discriminates too little to be a filter button, but it is not a defect and is not deleted; a term crossing that line either way is editorial news. The section is omitted when nothing qualifies. (@claude) (#267)
The monthly topic-coverage report now marks in the issue body what moved since the last run — a newly promoted term as
(new), and a term that left named beneath the list — rather than saying so only in a comment. The body is a standing dashboard, and a reader opening it later should not have to scroll the comment history to learn what recently changed. (@claude) (#268)An amendment to the narrative quadrants design specification, specifying the shape of the tutorials, how-to and explanation landing pages (
narrative spec §3.9). Reading spec §3.7 already rules those pages navigated rather than read, yet each still opens with a paragraph summarising its quadrant page by page — a prose list that has to track a directory, extended by hand in three separate pull requests and correct today only because no author has yet forgotten, which is the defect #193 names. The section sets an introduction stating what the quadrant is for without restating the site landing page’s Diátaxis one-liners, a headerless two-column table of links against terse descriptions matching thetable.autosummarythe reference quadrant already renders, a hidden toctree ordered as the table is rather than alphabetically, and a gate holding the two to one ordered list. (@claude) (#274)The tutorials, how-to and explanation landing pages now open with what their quadrant is for and then list its pages in a table, one terse description each, in place of a paragraph that summarised them one at a time (
narrative spec §3.9). That paragraph had to be extended by hand whenever a page was added — the how-to quadrant’s had grown from six clauses to nine — and nothing checked it.tests/test_docs_landing_pages.pynow holds each table and its toctree to one ordered list, so the two can no longer drift in membership or in order. (@claude) (#276)A getting started section: an overview, installation instructions for conda, pip, pixi and uv, a quick start that draws a real ascent, and a page of next steps (
start spec §…). Until now nothing in the documentation or theREADMEsaid how to installtephpyat all, while every page assumed the reader already had it. The quick start’s code is executed by the test suite and its figure compared against a baseline, like every other user page, which is what the four renamed section constants now reach. (@claude) (#279)An installation section in the
README, which until now had one heading and it was Non-Goals: a reader arriving by the GitHub or PyPI door was told whattephpydeclines to do and never how to install it (start spec §3.9). It showspip install tephpyand links the installation page for the conda, pixi and uv routes rather than reproducing four of them in a file with no tabs. A warning directly beneath that command says it does not work yet —tephpyhas cut no release — and is held to the version by the same test that retires the installation page’s note, now reading both carriers, so the release that makes one of them false cannot leave the other standing.scope spec §3.1is corrected in passing: it had described the status note as sitting above Non-Goals since #245 switched the two a week earlier. (@claude) (#284)Dead and stale links repaired in the design specifications, found by the new
ci-linkcheck. Two were failing:matplotlib’sAnnotationBboxandOffsetImagepages have moved from_as_gen/intooffsetbox_api.html(add logo spec §8). The rest are one upstream move —sphinx-tippynow belongs tosphinx-extensions2rather thanchrisjsewell— of which the two citations of its issue 32 were answering 404 while six further occurrences, of two other URLs under the same moved repository, were resting on a redirect that will not last forever. The frozen implementation plan keeps the URL it named, which is whyci-linkcheckdoes not read the plans. (@claude) (#285)A design specification for the contributor guide (
contributor spec §…): four new pages — contributing, testing, changelog and continuous integration — and the relationship between published prose and the agent-facingAGENTS.mdfiles. The measurement behind it is that five of six sampled rules are already written in three places, none of them published, while thirteen workflows and seventeen pixi tasks are documented nowhere a contributor would look. (@claude) (#287)An implementation plan for the contributor guide (
contributor spec §…), in six tasks. Written after measuring: four findings shaped it and one corrected the specification before any page was written —pixi_tasks.closure()removes the need for the hand-written exemption listcontributor spec §3.8had specified, since a task the page does not name is excused exactly when running a named task runs it. (@claude) (#288)A contributor guide in the published documentation (contributor spec §3.2, §3.3, §3.4, §3.5, §3.6): how to get an environment and what to run, how the tests are laid out, what a changelog fragment carries, and what each of the thirteen continuous-integration workflows is for — including what it means when one of the six scheduled ones reaches you unbidden.
CONTRIBUTING.mdandchangelog/README.mdbecome pointers keeping only the commands a gate holds them to, rather than a third copy of rules that were already written three times. (@claude) (#290)The parent design specification’s
plottingsection now has an internal structure, and every citation that lands on it names the part it means. The section was 413 lines under no subheadings at all, and 178 citations across the repository resolved to it — 170 of them insrcandtests, 42% of every citation of that specification — each one arriving at the same target. A reader following one arrived at a section and then had to find the paragraph.It gains seven thematic subsections, verified as a partition of all 413 lines with no prose rewritten: 22 lines added and none removed, five of them leads for the subsections whose cut falls inside a bullet list rather than on a paragraph boundary. 164 citations move onto the one each means, spread 11/32/33/24/33/14/17, and the 14 that stay on the section itself do so deliberately — eight are specimens of the citation grammar rather than references to the section, one is a changelog entry recording what the section had ruled before a later release reversed it, and five make a claim that genuinely spans the subsections. A design specification and an implementation plan are published alongside, and record the measurements the work was decided on.
The inventory was measured twice, and the second count is the one that holds. The first was one short, because the scan behind it required the word
specbeside the section number and one constant cites this section second in a compound run, where no such word stands next to it. Counting is now done with the project’s own citation grammar — the resolver the integrity gate itself uses — so no compound run, capitalisation or prefix fallback can hide from it. (@claude) (#295)The design specification now states who removes the side panels when a diagram is cleared, and the code that implements the rule cites it. The panels are the diagram’s to remove on a direct
ax.clear()and the figure’s on a figure clear, where the diagram’s teardown stands down rather than racing it —Figure.cleardeletes each entry of a snapshot offigure.axes, so an axes removing a sibling from inside its ownclearorphans an entry the figure is still about to visit.The rule was implemented, tested four ways, and written down nowhere (#294). Its one reference pointed at the whole
plottingsection, which does not state it — a citation that resolves without meaning, and the kind the section’s own re-anchoring (#293) was unable to fix, since it moved citations rather than writing specification. The four tests that assert the rule now cite it too, an absence being how it went missing in the first place. (@claude) (#298)The plotting tour is specified: a developer page that maps
tephpy.plottingfor someone about to change it, between the explanation quadrant written for a reader who has never opened the source and the docstrings, which are already strong (#66).The package is documented at both extremes and not in the middle. Its 124 docstrings each describe their own step and cite the section deciding it, and
spec §3.2specifies the design at 462 lines; what no file owns is the order things happen in and who owns what. Edge ownership reaches_sync_edge_labelsfrom two sites and_relayout_side_panelsfrom three, and no single docstring is the place to say so.The specification maps rather than accounts — every mechanism stays stated once in the tree and is cited, so
spec §3.2keeps every word and every anchor three days after #293 re-pointed 164 citations onto them. Relocating its living half is filed as #299, to be decided on evidence the page itself will produce. Citations run out only and name leaves rather than the container, which the census from #293 enforces without anyone having to remember, and two gates hold names rather than prose: whether a paragraph is still true is not gateable, and the specification records that rather than implying otherwise. (@claude) (#300)The plotting tour implementation is planned: three tasks covering the page, the two gates that hold its names to the package, and the changelog fragment (#66).
The plan carries the page and both gates in full, so the only value it defers is the pull request number. Its gates were run against its own page text before it was saved, which returned three defects — a name that belongs to a sibling package, a method that lands on no module, and a name the page mentions only inside a longer expression — all fixed in the text the plan carries rather than left for an executor to meet.
Two constraints are recorded because no gate would report them. A developer page’s python blocks are never executed, since
developeris outside the user sections in all three lists holding them, so the page carries no code blocks at all; and the citation corpus excludes plans by design, being point-in-time records, so a plan’s own citations are checked by nothing and this one’s were verified by hand. (@claude) (#301)The developer guide gains a tour of the plotting package: a map of
tephpy.plottingfor someone about to change it (#66).The package was documented at both extremes and not in the middle. Its docstrings each describe their own step and cite the section deciding it, and the design specification specifies the design at length; what no file owned was the order things happen in and who owns what. Edge ownership reaches
_sync_edge_labelsfrom two sites and_relayout_side_panelsfrom three, and no single docstring was the place to say so.The page maps rather than accounts. Every mechanism stays stated once in the tree and is cited rather than restated, so the specification keeps every word and the page stays free to follow the code; where the two disagree the specification is right and the page is the defect, which the page itself says. Citations name subsections rather than the section containing them, and run outward only — nothing cites the page.
Two gates hold its names to the package: the module map must be complete in both directions, and every symbol the page names must resolve by import. Each asserts a floor on what its parser found first, because a parser matching nothing would otherwise pass green. Whether a paragraph is still true is not gated, and
tour spec §5records that rather than leaving it implied. (@claude) (#303)Why the Axes Are Rotated gains a section on where a tephigram’s scales live, closing the last item of #66.
A fresh diagram carries no scales around its edges: every family labels its own lines inline, and naming an edge is what turns that edge into a scale. Which family can do that for which edge is a property of the geometry rather than a preference — a family can only label an edge its members actually reach, and the measured crossings make three otherwise-arbitrary things follow. Some families carry a whole scale from one edge while moist adiabats can carry none, being truncated curves that begin and end inside the view; a family usually needs two edges, since the isotherms reaching an edge do not all reach the same one; and every count belongs to the view rather than the family, which is why the crossings are worked out afresh as the reader moves around the diagram.
A section rather than a third page: the narrative quadrants design pairs each explanation page with the tutorial raising its question, and no tutorial raises this one. The content is continuous with the page it joins, moist adiabats being unable to carry an edge for exactly the reason the preceding sections give. (@claude) (#304)
The design specifications are swept for open items that have since resolved.
Ten items across seven specifications described work that has landed, and one status line described its implementation as pending. Each is corrected in place, the specifications being living documents: the status word changes, the original reasoning stays, and a dated note records what actually happened. Among them,
check-manifestwas carried as open in one specification and deferred in another when it had been adopted in #77; the review question #206 asked for exists as the fourth of docs-style’s Reviewing Claims; and the panel-teardown rule one specification recorded as stated nowhere is stated inspec §3.2.7, by the very fix that bullet named.Every closed reference was checked against the tree rather than against the issue state alone, an issue being able to close without the thing it asked for existing. Two triggers had fired without being read and are recorded rather than decided — the landing-table question two specifications hold jointly, and the tagging of a developer section whose rewrite has now happened — and two stale counts of that section’s pages are corrected. (@claude) (#305)
The developer guide’s landing page gains the shape the user sections already carry — an introduction, a two-column table, and a hidden toctree — and the set of sections that shape governs is now decided rather than deferred. The reference quadrant stays as it is.
Two design specifications each held half of this question open, and each said the two halves were one decision about which sections the shape governs. The argument for the shape extends to neither section: it is about a prose list drifting against a directory, and the developer guide carried no prose at all while the reference quadrant’s introduction guides rather than enumerates. What decides it is the job a row does — letting a reader tell a page from its siblings. In the developer guide nothing was doing it,
changelogthere being how to write a news fragment andchangelogin the reference quadrant the release history; in the reference quadrant the entries are reached by name, so there is no choice for a row to inform.The gate’s constant is renamed to say what it governs, two others of the same name meaning different things and all three having agreed until now. A subsection’s own landing page now counts as one entry of its parent, so the specification collection is a single destination in this table however many documents it holds. (@claude) (#306)
The developer section stays out of the topic corpus, decided rather than deferred. It was first excluded because the pages there were about to be rewritten, and tagging a set about to change files the wrong set; the rewrite has happened, so the question was taken up rather than deferred again.
The answer is unchanged, on a reason that cannot expire. The vocabulary names what a tephigram page is about, and seven of the section’s eight pages are described by no term in it, so the corpus being discovered rather than listed they would fail the gate until it grew terms for process. Such terms would be confined to one section, which the promotion rule never gives a button, and for its own stated reason: a term confined to one quadrant describes what browsing that section already solves — which the section’s new landing table now does. Tagging only the plotting tour would be worse than either, its terms being ones that do span quadrants, so a reader filtering for a tutorial would be handed a contributor’s map of module ownership.
The measurement is of each page’s subject against the table stating what each term covers and excludes, one page at a time, and the specification now records why it has to be: scanning for the words instead reports the documentation style guide carrying twelve of the seventeen, because it quotes example prose to illustrate what a review question catches. (@claude) (#307)
Added the what’s new design specification, for a reference section carrying the highlights of each release in prose beside the changelog’s record of every pull request. Modelled on
geovista, with four departures set out inwhatsnew spec §2, and validated by building a throwaway copy of the whole thing before writing it down. (@claude) (#319)Specified a grid of cards for the reference quadrant’s landing page. The decision of #306 to leave that page without a landing index rested on its introduction naming two of its pages rather than enumerating them, which was false when written: the opening paragraph had listed every page since #210, and #322 extended that list by hand. The specification now records the correction and gives the page seven cards, each with an icon in the root page’s vocabulary, held to its hidden toctree by the landing-page gate. Four other specifications that carried the old decision are brought into line with it. (@claude) (#327)
The reference quadrant’s landing page is now a grid of seven cards, each with an icon drawn in the root page’s vocabulary, in place of an introduction that listed its pages by hand (narrative spec §3.9).
tests/test_docs_landing_pages.pyholds the cards to the page’s hidden toctree as it holds the other sections’ tables, and now refuses an entry that reaches outside its section with... The root page’s cards share the renamedteph-cardclasses, and its dark Tutorials icon no longer rings its accent on the dark ground. (@claude) (#330)Specified phase 2 of the API version stamps in a new specification,
stamps spec, before the first release candidate needs it. Measured in a throwaway clone, the gate’s rule — every published object cites the version the next tag will carry — turnsmainred at the first merge-back of any tag, a release candidate included, over 94 correct stamps. The specification records a committed snapshot of the published API, written at the release freeze, that keeps released objects on their recorded version; and a merge-back through a branch named without a version, the only route by which a new object onmaincan be re-stamped without turningmainred (#227). (@claude) (#331)
💼 Internal#
Established the project foundation: packaging, pixi workflow, linting, typing, tests, documentation skeleton, and CI. (@claude) (#1)
The
docspixi task now depends ondocs-clean, sopixi run docsalways rebuilds from a clean tree — avoiding stale incremental renders (e.g. thesphinx_changelogtowncrier draft, whose fragment files Sphinx does not track as build dependencies). (@claude) (#28)Hardened the isopleth edge-label tests and tidied the bookkeeping they left behind (#53). An assertion that could not fail was replaced: the edge locator’s
tick_valueswas compared against the very list object its__call__assigns topositions. The side-panel gutter test’s unlabelled pad, until now only ordered against the widened one, is pinned to its expected value. New coverage was added for a family moving its own claim from the top edge to the right — a transition that releases one secondary axes and builds another inside a single resolve.TephigramAxes._claim_edgeno longer takes the family that itsnameargument already identifies. (@claude) (#59)Covered the two isopleth edge-label transitions that had none (#60). The
righttotopclaim move is now pinned alongside itstoptorightmirror — the harder of the two orderings, becauseEDGESvisitstopfirst, so it claims the new edge before releasing the old one. The barb gutter pad, until now only pinned as it widens to clear the right edge’s tick labels, is pinned as it narrows back on release too, by both routes that give the edge up: moving the claim to another edge, and dropping the labels withlabels=False. A third test records thatisobars()readslabels=Noneas “not passed” and so releases nothing. (@claude) (#64)Added a
pre-commithook that fails the commit when a design-specification citation stops resolving (#86). Citations name a section by number, so renumbering one stranded every reference to it silently — a stale citation is still a well-formed sentence. The hook also found 36 citations that did not meet the rule, including theadd_logo()specification’s own banner, which cited the parent’splottingsection but resolved to its own. (@claude) (#89)The documentation-link gate now checks every file named in its
SOURCESlist rather thanREADME.mdalone, and reads a URL written inside a quoted string (#100) — so.github/scripts/changelog.py, which sends a contributor to the documentation style guide by absolute URL, fails the build when that page moves rather than pointing at a 404 nobody notices. The gate is renamedcheck_documentation_links.pyto match what it now checks. (@claude) (#101)Every python code example in the how-to, tutorial and explanation documentation is now executed by the test suite, as one script per page and in document order, so that a snippet cannot silently stop working (#114). Each page’s figures are drawn as well as built, because
matplotlibdefers most of its validation to draw time. (@claude) (#118)The two Sphinx extension modules behind the design-specification citation cross-references are renamed
tephpy_citationsandtephpy_citation_xrefs, so that a documentation build no longer claims a generic top-level name ahead of every installed package onsys.path(#92).pixi run docsnow runs the two gates that read the HTML it just built, which until now ran only in CI, so a change breaking the rendered cross-references failed after pushing rather than locally (#91) —pixi run docs-htmlbuilds without checking. The details that govern how those gates and the shared citation grammar behave, each verified by mutation, are recorded beside the code they govern (#93). (@claude) (#119)Collapsed the duplicated
$TEPHPYRClookup in the configuration-file discovery cascade (#110). Building the cascade and discovering the file in force each read the environment variable independently, so a change to one would have diverged silently from the other, and the two could answer differently about which file was named. Both now take that answer from a single helper. What they do with it stays deliberately asymmetric: the cascade lists a named path whether or not it exists, sotephpy config pathcan report it as[absent], while discovery treats a named file that is missing as an error rather than falling through. (@claude) (#123)A design-specification citation written in a section heading now fails the documentation build (#96). The theme rebuilds its page navigation out of the headings, keeping the citation’s text and dropping its anchor, so a reader was offered a link to the section the citation sits in rather than to the one it names — and the gate that reads the built HTML scored it linked, because it cannot tell one anchor from another.
tephpy_citation_xrefsnow warns as it converts such a citation, naming the heading, which--fail-on-warningturns into a failure. The citation is still converted, so nothing renders differently; a table, topic or admonition caption is unaffected, being copied into no navigation. What is reported is wider than what is converted: a citation the author wrapped in a link by hand is left alone, because nesting anchors is invalid HTML, but it is reported from a heading all the same — Sphinx builds that navigation with a filter that discards an author’s link exactly as it discards the build’s. A citation quoted as a literal survives into the navigation as one and stays silent.The notebook branch of the citation pre-commit gate is now exercised by a notebook
nbformatwrote (#95). It locates a source line by searching for the shapenbformatwrites it in, which a fixture built in that shape by hand can only restate, andnbformatis declared in the test environment so that the check runs where the CI matrix runs rather than skipping there. The documentation-link gate gained the test that holds it to resolving its default sources against the repository root rather than the working directory (#102). (@claude) (#132)dependabot’spipecosystem is parked atopen-pull-requests-limit: 0, which stops version updates overrequirements/and leaves security updates running. Those files are not a lockfile to keep fresh: their>=lines declare tephpy’s dependency floors, and through[tool.setuptools.dynamic]they are also the publisheddependenciesmetadata. Its first run against them proposed lifting every floor to the newest release —xarray>=2024.10to>=2026.7.0among them — which narrows what a user may install tephpy beside without any evidence that the old floor was broken, and would have left the plannedci-floorsjob resolving a ceiling rather than a floor (#131). An advisory remains the one case where a floor rises automatically, with a person reading it. (@claude) (#140)The
ci-floorsworkflow resolves tephpy’s declared dependency minimums each week, at both sites that declare them — thepixitables inpyproject.tomland therequirements/pypi-*.txtfiles — installs what resolves, and then exercises it: thetesttier runs the suite, and thedocstier builds the documentation and checks its output. Every other job installs frompixi.lock, so until now each>=was a claim nothing had ever resolved (#78). A failure is attributed to a single package by relaxing one declaration at a time and re-solving, scanned upward for the lowest version that both resolves and passes, and filed as one issue per broken floor naming both declaration sites to edit. It is scheduled rather than a pull-request gate: it solves fresh against a live channel, so it goes red for reasons no pull request caused. Four declared floors are already known broken, and its first run reports them rather than this change fixing them (#135, #136, #137, #138). (@claude) (#141)The
ci-floorsjob that files an issue per broken dependency floor runs on the runner’s interpreter. It calledpixi execwhile installing no pixi, so the first live run diagnosed both broken tiers and then filed nothing (#141). (@claude) (#144)The test asserting that the
docstier declares the wheel builder now readspyproject.tomlfrom the index rather than the working tree. The conda half of the weeklyci-floorsjob rewrites every declared floor in the checkout to an==pin before running the suite there, so a literal specifier read from the tree fails thetesttier on every run and is reported as a floor that would not hold (#155). The declaration is a property of the repository, not of the tree that job leaves behind. (@claude) (#156)The generated floors manifest no longer defines features its one environment cannot reach. Only that environment survives generation, so every other feature was left defined and used by nothing, and the block of pixi warnings that produced opened each issue the weekly
ci-floorsjob files ahead of the failure the reader came for (#150). Whole features are dropped rather than the warning suppressed, which the generated manifest wants kept for its own defects. (@claude) (#157)The dependency floors job now resolves the floors a pixi
pypi-dependenciestable declares, against PyPI rather than against the conda channel. It read the four conda dependency tables and no others, so thedocstier’splaywrightfloor was never pinned and that tier ran green each week on a floor it had never tested (#151). The declaring table is carried through to the diagnosis, so the upward scan climbs the index the tier installs from and the issue the job files names the table to edit. (@claude)The tests that assert what
pyproject.tomldeclares now read it from the index. The conda half of that job rewrites the manifest in the checkout it then runs the suite in, dropping every feature its one generated environment cannot reach, so three tests reading the working tree failed thetesttier on the first run after they were written (#155). (@claude) (#159)The dependency floors diagnosis now copies the index into each probe. It stripped
.git, so the thirteen tests of thetesttier’s exercise that need a repository skipped themselves there – including the one that builds a wheel fromgit archive HEAD, which is the test a leg had failed on. The probe re-ran the exercise green and reported the failure as a step it does not run, when the step was a test it had (#154). Attribution also drops each probe as soon as it has answered, so a diagnosis holds one environment on the runner’s disk rather than one for every floor the tier declares. (@claude) (#160)A dependency floors issue now quotes what the highest version the scan tried failed on, beside the failure of the floors as declared. Where no candidate passes, the reason is usually a second broken floor, and the trace naming it was thrown away with the probe – leaving a verdict that read as “this package has no good version” when the package was sound (#145, #149). The issue also says why the two are not a contradiction: a relaxation only has to resolve, where a scanned version has to resolve and pass the tier’s exercise. (@claude) (#161)
The PyPI half of the weekly dependency floors job now diagnoses its own failures. Attribution and the upward scan ran for the conda half alone, and the filing job’s gate named only that half, so a PyPI-only failure went red in its own log and filed nothing. The two are one implementation over two resolvers now, pixi against the channel and
uvagainst the package index – with the three things the two declaration sites do not share reconciled: a relaxation pins what the default resolution chose, since--resolution lowest-directhas no per-package escape; the packages the two sites spell differently key on one name, so one broken floor raises one issue; and the requirements file named is the one that declares the floor, which need not be the tier the pixi table sits in (#142). (@claude) (#162)The dependency floors test that reconciles the two declaration sites’ package names now reads
pyproject.tomlfrom the index rather than the working tree, and a gate reports any other test that hands the manifest path to a call. The conda half of the floors workflow runs the test suite in a checkout whose manifest the generator has rewritten in place, so a test reading that file directly passes everywhere except the one job it exists to run in (#163). (@claude) (#164)The documentation workflow’s browser steps now bound each attempt they make, so a stalled browser install or demo check fails over to its retry instead of consuming the whole job budget, and the timeouts inside
check_browser_demo.pyare now small enough to fit within one such attempt. A test holds the two sets of budgets against each other. (@claude) (#165)The
docsjob now installs the browser demo’s Chromium without--with-deps, which escalated tosudo apt-getand stalled downloading a font package from the Ubuntu mirror. The retry beside it could not recover:timeoutsignals its own process group, but the runner may not signal a root process, so the stalledaptoutlived its bound, kept thedpkglock and failed the next attempt on the lock rather than on the network. Nothing is lost by dropping the flag — every library it installed is already current on the runner, and the only additions were fonts the demo never renders. A gate now rejects every spelling of the escalation in a retried step, because an attempt that cannot be killed is not one a retry can rescue. (@claude) (#166)The draw-time refusal tests now pin how the refusals split between those that raise and those that draw in silence, rather than only their total. A docstring had claimed sixteen and four of a table holding seventeen and five since #126 introduced both, because adding a row to each table and bumping the total leaves every other gate in the file green. (@claude) (#167)
Two figures the design specifications quote from the code are now gated against it. The documentation job’s budget is read out of its workflow and held against the figure the parent specification quotes, and the count of tests that stand down without a repository is counted by collecting them, four of the ten functions involved being parametrised. Both had gone stale in the pull requests that moved the value they quote, reported by nothing. (@claude) (#168)
The
docsjob now names the pixi task that owns each documentation gate rather than spelling its command out, so a contributor reproducing a failure runs what the job runs and there is no second copy of the command left to drift (#120). Every gate but the first skips its dependencies: pixi deduplicates a shared dependency within one invocation and not across several, so without the flag each step would clean and rebuild the documentation in full — the saving the issue assumed was already being made.--skip-depsskips every dependency though, not only the one an earlier step supplied, so a gate given a new dependency later would quietly be run without it. A gate now holds each use of the flag to a dependency an earlier step has already run — counting only what a step is certain to have run, since a task reached down the far side of a||need never be reached at all. The test that finds the job’s network-facing steps resolves a task name to every command running it runs, dependencies included, having until now recognised those steps by a script path the workflow no longer spells out. (@claude) (#170)Every figure the documentation publishes is now compared against an approved baseline whenever the documentation is built, keyed on the name each block declares rather than on a per-document counter, so inserting a section no longer renumbers every image after it. A change in what a snippet draws fails the build rather than reaching the published page unnoticed. Re-approve an intended change with
pixi run docs-figures, which reports the RMS of each image it updates. The gate runs in CI beside the citation and link gates, and the baselines are pruned from the source distribution. (@claude) (#172)pixi run docs-allis a new task running every gate the documentation job runs: the three that read the HTML it just built, and the browser demo that drives the built page in Chromium, which until now ran only in CI — so a change breaking that demo failed after pushing rather than locally (#171).pixi run docsstays the fast check the contributing guide and the pull request template name, and the browser demo is the only gate it leaves out, because no environment here carries the Chromium it drives. Where that browser will not start,check_browser_demo.pynow names the command that installs what is missing instead of reporting a page of browser log. A test holds both tasks to the job: what they run between them is what it runs, and the browser demo is the whole of the difference. (@claude) (#177)The weekly dependency-floors job now runs its documentation tier by pixi task rather than by hand. Spelled out step by step, it ran two of the three gates
pixi run docsruns — the figure gate had joined that task and this copy of the list never heard about it — while the design specification described the exercise aspixi run docs, a third spelling and wrong the other way (#178). Naming the tasks leaves the manifest as the one place those commands are written, and the diagnosis that reproduces a failing tier names the same three. The figure gate is still left out, and now says why: it compares the published figures against baselines blessed under the matplotlib the lockfile pins, so at a floor matplotlib it would report the distance between the two — which grows every time the lock moves and never on account of a floor. Three tests hold the tier to that set: what it runs against whatpixi run docsruns, the diagnosis against the workflow, and every--skip-depsagainst a step that has already run what it skips. (@claude) (#179)The test suite no longer needs pytest started a particular way to import its own helpers (#185). The tests tree is a package and three of its modules import a sibling through it, but under
--import-mode=importliba pytest below 8.2 leaves that dotted name tosys.path— where the repository root sits only ifpython -m pytestput it there, and not if thepytestconsole script started the run. So thepytest >=8.0floor had been a false claim since #179 added the import, and the weekly floors job found it as one half of the same run red and the other green: the two halves start pytest the two ways.pythonpathnow names the root, so neither half depends on the invocation, and a new gate holds the declaration to the imports that need it — the root on the path pytest builds, the modules still importing through the package, and a table of every shape that import takes. (@claude) (#187)The weekly dependency-floors job no longer tells every unattributed issue that relaxation ran and found nothing (#188). The diagnosis reaches nothing attributed three ways — the solve failed and no relaxation of it helped, the floors resolved and the tier’s exercise then failed, or the exercise then passed and so reproduced nothing — and the finding recorded only the verdict, so all three were filed under the one sentence that fits the first, pointing at “the solver output”. #185 was the second: it quoted a
ModuleNotFoundErrorunder failure at the declared floors and sent its reader after a dependency conflict that did not exist. The finding now records which route it came out of, and the issue picks its sentence, its starting point and each quoted block’s label from that; a route the composer does not recognise says only that the diagnosis did not record how far it got, rather than inheriting a claim. The two halves are diagnosed separately and can stop at different routes, so each half’s line says what that half’s diagnosis did and what the route means is said once for the issue, only where the two agree; where they differ each quoted block is labelled by its own half’s route and the sentence sending the reader to them names neither. The route stays out of the deduplication key, a tier that fails to solve one week and fails its exercise the next being one broken thing, and two gates hold the two scripts to one vocabulary. A two-half issue also no longer says each half scanned its own source when nothing was attributed and so nothing was scanned. (@claude) (#190)The four page-shape checks of
tests/test_docs_snippets.pythat were traced by hand when #172 added them are now proven additive by mutation (#175): each was made to fail on its own, and the mutation that isolates it is recorded beside it, so the next reader inherits evidence rather than an argument. One of the four had to be mutated in the opposite direction from the obvious one — adding a:filename-prefix:to a suppressed plot declares a figure with no baseline and fails a second test, which proves nothing about the first.The undocumented composition in the same module is pinned too. A figure page’s mixing check compares the language for equality, so every other spelling of python reaches the near-miss check instead, and nothing held the two together;
test_the_two_language_checks_composenow measures both against a literal list of the spellings that mean python. Both classifications are shared helpers rather than a comparison written out at each site, so the oracle asks the predicates the page scans ask: reimplementing them would have let the figure-page check stop folding case while the oracle, folding on its own account, went on reporting that nothing escapes. Writing that oracle showed theNEAR_MISSdocstring’s claim to cover “every spelling Pygments resolves to the python or the python-console lexer” to be false in both directions — Pygments’ Python lexer also answers tobazel,pyi,sageandstarlark, whileipythonandipython3belong to no bundled lexer at all. The docstring now says what the set is and which exposure is left standing on purpose. (@claude) (#200)A pre-commit hook checks that a page links a glossary term the first time it names it (#209). docs-style has asked for that since the glossary was written, and nothing enforced it: the fail-on-warning build rejects a
:term:whose entry is missing, but says nothing about the link nobody wrote, so a page could name every term in the glossary in plain text and build green. Sixteen such mentions across eight pages are linked in the same change, including three in the opening two sentences of the how-to whose whole subject is isopleth members. The check reads source rather than the built HTML, so it needs no documentation build, and it groups a glossary entry’s aliases — linkingCAPEsatisfiesconvective available potential energytoo. (@claude) (#211)tests/test_calc.pynow covers the one route to a supersaturated parcel start that a constructedSoundingcannot rule out. Its dewpoint validation is per level, so a"surface"parcel — which starts from a level — can never reach Normand’s point without one; a"mixed-layer"parcel is averaged rather than selected, andmetpy.calc.mixed_parcelaverages potential temperature linearly while mixing ratio averages nonlinearly, so across a saturated layer with a cold notch inside the mixed-layer depth the mixed dewpoint overshoots the mixed temperature. Bothcalc.parcel_pathandcalc.indicesare covered, the way the neighbouring mixed-layer guards are, and the surface parcel is asserted to lift cleanly from the same sounding so the test pins the averaging rather than the profile. Both were verified by mutation.tests/plotting/gains the cover for the configuration-driven raises this sweep documented: thatIsoplethFamily.set_visiblere-resolves every tier in either direction of a real visibility change — so atephpy.configvalue gone bad raises on hiding as readily as on showing — and that a no-op call re-resolves nothing, which is the short-circuit that makes a careless probe under-report the hiding case. The accessor’s malformedemphasistest is parametrized over all four shapes theRaisessection enumerates, rather than resting the enumeration on one member. (@claude) (#223)Every object the API reference publishes now records the version it arrived in, as a numpydoc
Notessection carrying.. versionadded:: 0.1.0— the form thirteen files under.github/scriptsanddocs/src/_extalready used andsrc/tephpynever did.check_api_docstrings.pyis the gate, enforced bytests/test_api_docstrings.pybecause it importstephpyand so cannot run in the isolated environment pre-commit builds; numpydoc enforces neither this norRaises, its checks stopping atRT05andSA04(#227). The rule while no tag exists is equality rather than a bound — nothing can predate the first release — against the versionsetuptools_scmsays the next tag will carry, read throughConfiguration.from_fileso the gate cannot drift from the build, and declined altogether on a shallow checkout where the derivation is untrustworthy.The gate owns a reusable definition of the published API, which
check_api_inventory.pyholds to theobjects.inva real build writes. That test earned its place immediately: the first enumeration found 156 objects against the published 94, every one of the 62 extra a re-export. The rule that settles it — an object is published by the module that defines it, sovarsrather thandirthroughout — is not one the module layout would suggest, and neither istephpy.config, a private module’s singleton whose methods are published while its property is not. The stamping was generated from that enumeration rather than typed, so the gate is green by construction rather than by 94 correct guesses. (@claude) (#229)The API docstring gate gained its second rule: a published object must document the exceptions it raises.
numpydochas noRSfamily — its checks stop atRT05andSA04— so until now aRaisessection could be absent or stale andpixi run lintstayed green, which is how three public classes came to validate their arguments and document none of it (#224, fixed by hand in #223).The rule is narrow on purpose: only an exception raised in the object’s own body, with nothing propagated through the calls it makes. Measured over the package, propagation is where the false positives live — it cannot follow dynamic dispatch, cannot see
datetime.fromisoformat’sValueErrorbehind a correctly documented one, and reports internal guards that no caller can reach. So documented but not found is never an error, and only the missing direction is checked. A class is read through its constructor hook rather than its own body, because that is where a dataclass validates and the class docstring is the only one a reader is shown; a rule reading the class body would have missed the largest defect #223 found by hand. Verified against that defect: removing theRaisessection #223 added toSoundingmakes the gate report it.Whether a raise escapes is decided by walking outward through the
tryblocks that enclose it. A handler swallows it, unless that handler re-raises bare, which puts the same exception back on its way out — so the walk continues from there. Both simpler readings are wrong in opposite directions: ignoring bare re-raises loses aValueErrorraised in atrybody and re-raised by its own handler, while crediting a bareraisewith everything its handler catches invents one onIsoplethFamily.configure, which rolls back and re-raises underexcept Exceptionaround a body that raises nothing at all. (@claude) (#230)Thirteen docstrings across
plottingrestate one configuration-driven raise set by hand — every family accessor,clear,edge_axis,configure,set_visible,_resolveand the two emphasis helpers — and nothing kept the copies in step, so they drifted (#226). Two had already gone incomplete and were fixed in #223.Three divergences are closed here.
IsoplethFamily.configure, the copy the other twelve delegate to, was the vaguest of them — an open-ended “e.g. a non-positiveinterval” where its own callers enumerate four conditions — and now enumerates them too. ThreeRaisessections listed their entries out of alphabetical order, which the other docstrings follow:_resolve, and alsocalc.normand_pointandTephigramAxes.fit, which the issue had not spotted.What holds them from here is a relationship rather than a fourteenth copy of the list. The gate checks that every published
Raisessection is alphabetical, andtests/plotting/test_axes.pyderives the option names an accessor may mention from the five accessors themselves, then requires each to name exactly those its family accepts. Somixing_ratiosomittingintervalis correct — it takes none — while an accessor quietly dropping one it does accept fails, which is verified by mutation.The option vocabulary is read from the specs too, rather than listed in the test. A list there would have been a second registry inside a check whose purpose is to have no second registry: an option the specs gained but the list did not would sit outside every comparison, free to drift between accessors unnoticed. Proved rather than asserted — giving
mixing_ratiosanintervalit does not have makes its prose wrong for the family it describes, and the check says so. (@claude) (#231)setuptools_scmdeprecatesrelease-branch-semverin favour ofsemver-pep440-release-branch, and #229 suppressed the warning where the API gate reads the configuration rather than renaming release-critical configuration as a side effect (#228). The rename is made here, with the[build-system]floor it requires: the new name arrives insetuptools_scm10, and at the previous>=8floor it is not registered at all. Both move together, the suppression is gone, and the derived version is unchanged —0.1.0, now with the gate’s derivation running clean under-W error, which is what shows the deprecation is gone rather than hidden.Two gates keep the pair together, because they were nearly not.
test_the_configured_version_scheme_is_registeredasks whicheversetuptools_scmis installed, which in a pull request is the lockfile’s — so it would have passed a rename made at the old floor, and onlyci-floors, which runs weekly rather than as a gate, would have caught it days later.check_api_docstrings.py’s siblingcheck_version_scheme.pycloses that window: it resolves the lowest non-yanked release satisfying the declared floor, installs it, and reports whether the configured scheme is among the ones it registers.ci-testsruns it on every pull request.Reading the wheel’s
entry_points.txtinstead of installing would have been faster and wrong in the direction that matters: fromsetuptools_scm10 the schemes are registered by itsvcs-versioningdependency, so the wheel’s own table lists none of them and the check reported every scheme as missing — including the one that works. What a floor knows is what it resolves to. (@claude) (#232)Audited the three
AGENTS.mdfiles, and fixed what they got wrong about the repository they describe.Two of them were unreachable. The root
CLAUDE.mdexists because Claude Code readsCLAUDE.mdand notAGENTS.md, and imports its sibling so the one file stays the single source of truth — butdocs/AGENTS.mdandtests/AGENTS.mdhad no such sibling, so every word of directory-local guidance reached only the agents that readAGENTS.mddirectly. Each now has aCLAUDE.mdimporting the file beside it.The root file’s architecture line had an arrow pointing the wrong way:
transforms←plotting← (calc,sounding,io) putscalc,soundingandioaboveplotting, and nothing in the package importsplottingat all. The imports run the other way —plottingreaches fortransforms, and forcalcandsounding(plotting/axes.py), whileiobuilds aSounding(io/igra.py) — so the line now namessoundingandtransformsas the base,calcandioas building onsounding, andplottingas sitting on top.tests/AGENTS.mdclaimed “each subpackage has a matching directory”, which holds forexamples,ioandplottingand fails forsamples, whose tests aretests/test_samples.pyat the root. The rule is really about modules rather than subpackages:tephpy.samplesis a lone__init__.py, so its tests sit where a top-level module’s tests sit. Stated that way, with the case that reads as an exception named.docs/AGENTS.mdwarned against hand-writing the autoapi reference but said nothing aboutdocs/src/gallery/, which sphinx-gallery generates fromsrc/tephpy/examplesand which is untracked — the tree most likely to be edited by mistake precisely because nothing in git records that it is derived. Both generated trees are now named together. The root file also pointed attests/AGENTS.mdwithout ever mentioningdocs/AGENTS.md; it points at both. (@claude) (#233)tephpy.samplesis now tested fromtests/samples/rather thantests/test_samples.pyat the tests root, andtests/test_layout.pyholds the tree to the mapping so the next subpackage cannot arrive without one (#234).The rule had been written down three times and enforced none of them. Design spec §8.5 has said “each subpackage gets a matching directory” since #15, four weeks before
tephpy.samplesexisted.tests/AGENTS.mdsaid the same thing until #233, whose audit found the guidance and the tree disagreeing and resolved it towards the tree — on the strength of the examples-gallery plan, which had recorded the root placement as though it were the rule whentephpy.sampleslanded in #181. That was the wrong authority: plans are point-in-time records of what was done and specs are the living statement of what should be, so the deviation belonged in the tree, not in the rule. Nine days and one audit is what it took to notice, and the guidance is back to the spec’s wording.The gate is the part worth keeping. It asserts the mapping in both directions — every subpackage has a directory, every mirroring directory has a subpackage — and that each one is a package, which is the condition the sibling imports through the tests tree depend on (#185). Shared
fixtures/andbaseline/are excused by name, and every other directory takes part whatever it happens to hold. The first version of the scan did not manage that: it also asked for atest_*.pybeside a directory, which is exactly the inference namingSHAREDexists to avoid, and it passed in silence over the three shapes worth catching – a directory holding nothing, one holding only nested tests, and one whose subpackage was deleted out from under it. Review caught the contradiction, the predicate is gone, and each of the three was then forced and watched to fail.The gate was written before the move and run against the tree as it stood, where it failed on
samplesalone; the four failure messages were each read from a forced failure rather than assumed, since an assertion message is the one part of a gate its passing run never exercises.The declared
SUBPACKAGESset exists for the same reason: every mapping assertion is satisfied by finding nothing, so the scan is pinned to a corpus that fails loudly when it breaks, the waytests/test_pytest_config.pypins its importers. (@claude) (#235)The property test over the citation grammar can now build the split-prefix wrap.
PIECEScarriedlogo speconly as one joined atom, so a wrap could never partlogofromspec, and the first of the three known line-span divergences was outside the generator’s reach at anyrepeat— guarded by two hand-written cases rather than by the property meant to stand for the unknown fourth. Verified by mutation rather than by adding the piece and assuming: with the prefix-internal gap widened to\s+, the property passes before the change and fails after it. (@claude) (#244)ruff-formatno longer runs over markdown. Version 0.16.5 addedmarkdownto that hook’s owntypes_or, so #242 — an automated hook update — put every fenced block underdocs/src/developer/through the formatter, which formats each fence as though it were a module. The blocks there are excerpts rather than modules, and three were reinterpreted: a trailing comma separating two parameters of a signature became a one-element tuple, and two statements lifted from inside a loop were dedented to column zero. The hook is pinned back to Python file types, and every file #242 reformatted is restored. (@claude) (#246)Spec §8.5’s placement sentence is now asserted, not only stated. A test module named after a module of the package must sit in the directory mirroring where that module lives, so a
tests/test_igra.pyat the root fails becauseio/igra.pyis in a subpackage. The rule fires only on a collision, so a test naming nothing in the package — most of the tree, and legitimately — is out of scope by construction rather than by an exception list. Each module basename having one home is asserted beside it, because that is the answer the rule reads. (@claude) (#247)Closed the two deferred minors in the published-figure gate. A
:filename-prefix:carrying a dot is no longer read as a declaration — matplotlib refuses one, so it names a figure the build never writes, and the near-miss detector now reports it instead of the gate hunting for a missing image. And a failed comparison’s-failed-diff.pngis moved out of the built site before the gate returns:compare_imageswrites it beside the built image, which is the directory Read the Docs publishes, and this gate runs after Sphinx has already produced a complete and publishable tree. It is moved rather than deleted, because the advice sends a contributor to open exactly that file, and its new path is named beside the measurement. (@claude) (#248)pixi.lockrecords the project’s own requirements as well as resolving them, and nothing regenerated that copy when a floor was raised, so the lock could go on asserting a floor the project had already left behind — as it did for six of them, until an unrelated pull request happened to re-solve. The test suite now holds the lock’srequires_distto whatrequirements/pypi-*.txtdeclares, in both directions, and fails rather than passes when it can read neither. (@claude) (#254)pixi.lockpins the packages every job installs, so left alone CI goes on testing whichever resolution was last written. A newci-locksworkflow re-resolves it weekly, regenerates therequirements/tephpy.ymlconda environment file beside it, and opens a single pull request with the diff as its body — proposed by a bot account rather than pushed, so the checks that guard both files actually run on it. (@claude) (#255)The weekly lock refresh decided whether anything had changed by measuring the length of the rendered summary, which reads
No diff— eight bytes — when nothing has. At a threshold of ten that sat two bytes from passing an empty update through, and it would have failed open, silently, into a weekly pull request with nothing in it. It now askspixi updatedirectly instead. (@claude) (#257)The by-path module loader is written once.
importlib.util.spec_from_file_locationwas open-coded in 18 test modules (#265), and the copies had drifted over whether the directory goes onsys.pathbefore the module runs — load-bearing wherever a module resolves a sibling by top-level name, asdocs/src/_extand.github/scriptsboth do.tests/ext_modules.pybecomestests/by_path.py, withload_ext,load_scriptand theload_pathprimitive beneath them, and a test holds the tree to one copy by reading each module as syntax rather than as text. (@claude) (#269)Two workflows now carry over from
geovista.ci-first-contributiongreets and labels an author’s first issue and first pull-request, triggering onpull_request_targetbecause GitHub withholds secrets from apull_requestrun raised from a fork — which is where a first contribution comes from.ci-stalelabels an item stale after 180 idle days and closes it 28 days later, exempting the standing reports’ own issues: each finds its issue again with--state open, so one closed for inactivity would cost the history it carries.tests/test_stale.pyderives those exemptions from the report scripts and parses them the wayactions/staledoes. (@claude) (#270)The distributions now carry the package rather than the repository around it.
MANIFEST.inlisted inclusions layered over a file finder that already took every tracked file, so the sdist shipped 450 files for a package of 55; it now names exclusions, and the sdist carries the package, its runtime requirements and a build’s metadata — not the tests, documentation, changelog fragments or CI. A release is tested before it ships, so the tests in every install buy a rare failure whose remedy is a development environment anyway.pixi run manifestrunscheck-manifestandci-wheelsruns the same task, which the inversion makes capable of catching aprunethat silently stops matching (#77). (@claude) (#272)A scheduled
ci-linkcheckworkflow, ported frombjlittle/geovista, for the external links nothing here has ever checked —check_documentation_links.pyjudges only URLs onto this project’s own documentation host, leaving 110 distinct URLs in the published documentation and 16 more in theREADMEunwatched. It is weekly and never a pull-request gate, for the reasonci-locksandci-topicsboth record: it reaches the network, so it fails for causes no pull request created. Findings land in one standing issue, edited in place.A second job covers
WYOMING_URL, which is the one external URLtephpycalls rather than links and which a link checker cannot judge: the constant holds its{datetime}and{station}braces unformatted, and the archive answers that literal string400while it is perfectly healthy. Scanning it as text would report a break every week and stay silent on the day it truly broke — so.lycheeignoreexcludes it andcheck_wyoming_endpoint.pyaskstephpy.io.wyoming.fetchfor the ascenttests/fixtures/io/recorded, which is the same code path a user’s request takes. (@claude) (#285)A citation of a section that has subsections resolves, and no rule can say whether it should: a claim spanning the whole section has nowhere better to point, while a claim about one paragraph leaves the reader to find it. Rather than gate a judgement no gate can make,
tests/test_citations.pynow keeps a census —containers()finds every anchor another anchor’s slug extends, and the test records which citations land on one, outside the specifications themselves. The record is keyed by file and anchor rather than by a total, so a citation removed from one file cannot pay for one arriving in another, and by file rather than by line, so an edit above a citation does not churn it. A change fails, and the message names the file and both counts.Recorded at 36 citations over 21 file-and-anchor pairs, across six of the 23 container anchors. Measuring it corrected the premise of the open item that asked for it (#293): the
plottingsection is not the only subdivided one, and section 5 of the configuration-file specification carries as many container citations over two subsections asplottinghas left over seven — filed as #296 to be measured rather than assumed. (@claude) (#297)Granted
contents: readto every workflow job that runsactions/checkout(#286). These workflows open withpermissions: {}, which sets every scope tononefor every job in the file, so twelve jobs were cloning the repository holding no grant to read it — working only because tephpy is public, and depending on that in no file that says so. The issue named the three jobs whose omission was visible, those being the three that declare apermissionsblock naming something else; the other nine declare no block at all and were short by the same mechanism.tests/test_workflow_permissions.pyderives the rule rather than listing the jobs, and resolves each grant the way GitHub resolves it — a job’s block replaces the workflow’s rather than merging into it, which is the reading that would have cleared all nine. (@claude) (#308)Replaced the detector behind floors spec §3.4’s quoted count of index-guarded tests with a live one (#273). The old gate walked the test sources for four spellings of a guard and said in its own docstring that a guard reached through a helper imported from another module was a fifth it could not see — which is precisely the shape the consolidation of #273 creates, and it fails open: the guarded test still skips, the count silently drops, and the prose gets edited down to match a suite it no longer describes. The gate now runs the candidate modules in two copies of the committed tree, one carrying a repository and one not, and takes the difference of what pytest reports skipped, so neither a new spelling of the guard nor a new wording of the skip reason escapes it. Measured against a test reached through an imported helper: the old detector does not see it, the new gate counts it.
A difference reads a change of status rather than a guard, though, and the two part company over a test standing down in both runs for a reason of its own — a guard on the index added to that test moves nothing, and the count stays green (#309 review). Those tests are now named rather than inferred: the set is asserted by equality, so one joining it fails until someone has said why, and each member is held to naming no index. Correcting that raised the quoted figure from thirty-seven to thirty-eight, the gate itself being an index-guarded test that the earlier difference could not see.
_needs_the_indexand its four-spelling machinery are gone. (@claude) (#309)Consolidated the nine copies of the committed-file reader into
tests/committed.py(#273). Eight sites undertests/now share onecommitted()and onecommitted_manifest();.github/scripts/check_version_scheme.pykeeps a copy of its own, since that directory is not an importable package and an exported tree does not carry it at all, so a gate there importing fromtests/would fail where the shared module merely skips.The drift this was filed to prevent had already happened, in the wording. Every copy said it read the file “from the index”. None did —
git show HEAD:reads the committed tree, and the index would begit show :pyproject.toml. The distinction is live: a contributor who stages a new pixi task and runs the suite before committing would take a pass from a manifest that does not have it. The same sentence is corrected intests/test_lock.py,tests/test_environment_file.py,tests/plotting/test_logo.py, the version scheme gate, and floors spec §3.2. Where the word was right it is left alone: the corpus enumerators reachgit ls-files, which really does read the index.Two gates would have gone quiet on the move, and both are widened rather than left to be noticed.
tests/test_floors.py::test_every_call_that_shells_out_to_git_is_guardedglobbedtest_*.py, so the suite’s one remaininggitcall — now in a module that is not a test — would have been the first thing it stopped watching; it reads every module undertests/and asserts it found a call. And the candidate filter behind floors spec §3.4’s quoted figure read sources for.git, which three modules no longer name; measured, that filter alone takes the count from thirty-eight to thirty. (@claude) (#311)Said “repository” where the suite meant a repository (#273 follow-up). #311 corrected the places that described
git show HEAD:as reading “the index”; the word was also standing in for a repository being present in five modules and in floors spec §3.4 — a guard on(REPO / ".git").exists()asks whether the directory is there, and says nothing about the staging area. Renamed with it: three tests, and the environment variable that stops the count gate re-entering itself.Where the word was right it is left alone, checked one site at a time rather than swept.
tests/test_citations.pyandtests/test_github_references.pyenumerate their corpus throughcheck_citations.py, which runsgit ls-files— that really is the index — and every reference to a package index or to a conda channel is untouched. (@claude) (#312)Stopped the API docstring gate trusting a version it never derived (#310).
vcs_versioningreports0.0where it has a commit and no tag to measure it from — the state of a tree exported bygit archivefrom a repository carrying no tag, whose substituted.git_archival.txtthen holds anodeand an emptydescribe-name. Taken as a target it told a contributor to stamp all 94 published objects.. versionadded:: 0.0, and failed three tests on a condition that says nothing about the code.target_versionalready promisedNone“where the derivation cannot be trusted” and asked only whether the checkout was shallow — a proxy, and one whose own command fails in a tree with no repository, where a failed command read as “not shallow”. It now asks the question it documents.Refusing every tree without a repository would have been the same mistake once more: measured, a tree exported after a tag carries no repository and derives
0.1.0correctly from that same file. So the one test asserting the exact derived version is guarded on a version having been derived, not on.gitbeing present, and it still runs in a tagged export. Floors spec §3.4 moves from thirty-eight to thirty-nine with it. (@claude) (#313)Added Cutting a Release, the sequence a release happens in — what to check between the steps, the two actions that cannot be taken back, and the one-time setup the first
v*tag needs. The steps existed only as a single prose bullet in spec §10, which says what release execution covers rather than what order to do it in; a first release is a run of one-shot actions, and one of them consumes a PyPI version number forever.pixi run changelogassembles the news fragments, taking the version the tag will carry. It was the one thing the release sequence needed that was not a task, and a raw command in a runbook is the line no gate holds to anything — it is now covered by the existing task gates like every other.The page’s task table is read by the same gate that reads
contributing.rst’s, generalised over both: a runbook naming a task that has since been renamed fails at the moment its reader can least afford it.Every release is tagged from a dedicated
vA.B.xrelease branch, never frommain, and the branch is merged back once the tag is out. That is not ceremony: measured, one commit pastv0.1.0derives0.1.1.dev1onv0.1.xand0.2.0.dev1onmain, so the branch is what tellssetuptools_scmthat the patch line and the next minor line are different lines — andmainderives the wrong one until the merge-back lands. The branch is also where a fix for a released version is prepared.ci-tests,ci-docs,ci-wheels,ci-citationandcodeqlnow run on it as they do onmain, since a branch nothing checks would put the least-tested commit in the repository on PyPI;ci-testsandci-docsrun on thev*tag too, so the released commit carries checks of its own. Cross-referenced againstbjlittle/geovista, which has released this way several times:publish-testpypistays scoped tomainas geovista scopes it, and the two places tephpy is deliberately stricter —codeqlon release-branch pull requests, and av*.*.xbranch pattern rather thanv*x— are stricter on purpose.Step 5 says where a patch release loses the Test PyPI rehearsal, and gives the
distartifact as the way to get the same confidence.Step 8 links the PyPI project page and gives the throwaway-install check. Step 9 gives the merge-back commands and the part that is not the default: the pull request is merged with a merge commit, not the squash
mainis configured for. A squash lands the branch’s content while leaving the tag offmain, after whichsetuptools_scmderives a version below the one just released — a merge commit makes the tag an ancestor and movesmainto the next minor line (0.2.0.dev…against the branch’s0.1.1.dev…, measured both ways). Two settings are relaxed for that merge and restored after it — merge commits enabled, andmain’s Require linear history turned off — and the page says which, in which order, and why the window is worth keeping short.The sequence names the
skip-changeloglabel the release pull request needs (#315 review). That pull request deletes every news fragment intoCHANGELOG.rst, soci-changelogfinds none named for it and, becausedorny/paths-filterreports deletions like any other change, fails on the deleted paths rather than reporting a missing fragment. Nothing applies the label for a human’s pull request. (@claude) (#315)Made the pre-release-note gate one-sided (start spec §3.7): a released tephpy must not carry the note, and an unreleased one is no longer required to.
The two-sided form deadlocked the release it was written for. The signal changes when the repository is tagged; the note is removed when a file is edited; and those are different commits over the same tree — so requiring the note while unreleased forbade removing it from the commit that gets tagged. That commit is the one that is built, with
README.mdas the distribution’s long description, so published0.1.0would have told every reader of its PyPI page and of thestabledocumentation that tephpy had never been released, with the correction landing in0.1.1. A release candidate does not lift it either:packagingsorts0.1.0rc1below0.1.0, so the whole rehearsal would have run with the note still demanded.What survives is the direction that protects a reader, plus a new check that the two carriers agree with each other — which is what the second carrier was added for in #284, and the only part of the old rule that was doing that work. Both directions verified by mutation. (@claude) (#317)
Added the implementation plan for the what’s new section (
docs/src/developer/plans/2026-09-14-tephpy-whatsnew.md), derived fromwhatsnew spec §1and frozen once its pull request merges, per docs spec §3.4. Six tasks, each with its failing test written out. (@claude) (#320)Assembling the changelog no longer fails the container census. The census is keyed by file, and a release deletes every fragment and writes its text into
CHANGELOG.rst, so four of its rows moved at once on the commit that gets tagged — and would have again at every release (#318). It now leaves out what a release moves:CHANGELOG.rst, and every fragment, recognised by towncrier’s own rule rather than by directory, so the hand-writtenREADME.mdand template stay counted. Every citation in both still has to resolve. What is given up is the census noticing a new fragment that cites a subdivided section, which it had done once. (@claude) (#325)Planned the reference quadrant’s card landing page in six tasks, from the specification of #327. Planning found that the landing-page gate lets an entry reach outside its section with
.., and the plan closes it. The plan’s code was run from the plan’s own text before it was proposed, which caught a line the linter rejects and a mutation predicted to fail fewer checks than it does. (@claude) (#329)Planned phase 2 of the API version stamps in five tasks, from the specification of #331. The plan’s code was run from the plan’s own text before it was proposed, which caught three lint errors an executor would have met at the first commit, and five expectations that did not account for every test their filter selects. (@claude) (#332)