From f74c945a3f80e5f010ef62fcb4fe2c14286e83bf Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 10 Sep 2026 22:05:46 -0400 Subject: [PATCH 1/9] DOC: fix some remaining docs build warnings Assisted-by: opencode:claude-sonnet-5 --- docs/conf.py | 63 ++++++++++++++++++++++---------- docs/known_issues.md | 79 +++++++++++++++++++++++++++++++--------- src/powderline/schema.py | 5 +++ 3 files changed, 111 insertions(+), 36 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c229316..965554f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,27 +62,48 @@ # Treat missing cross-reference targets as warnings (not silently ignored) nitpicky = True -# RefinementParameter fields are `Annotated[tuple[...], PlainSerializer(...)]`. -# Sphinx's type-hint renderer expands the PlainSerializer repr into bogus -# sub-references (its keyword args and the fully-expanded Annotated/list/dict -# forms) that can never resolve to real objects. Silence just those synthetic -# targets; genuine unresolved references still warn normally. -nitpick_ignore_regex = [ - ('py:class', r'^func=.*$'), - ('py:class', r'^return_type=.*$'), - ('py:class', r'^when_used=.*$'), - ('py:class', r'.*PlainSerializer.*'), - ('py:obj', r'.*PlainSerializer.*'), - ('py:class', r'^ConfigDict$'), -] - -# pandas' public docs index `pandas.DataFrame`, but runtime type hints resolve -# to its internal module path; the pandas intersphinx inventory has no entry -# for the latter, so it can never resolve. +# cases with out a link target nitpick_ignore = [ + # pandas' public docs index `pandas.DataFrame`, but runtime type hints + # resolve to its internal module path; the pandas intersphinx inventory + # has no entry for the latter. ('py:class', 'pandas.core.frame.DataFrame'), + # pydantic's `Field(gt=..., ge=...)` constraints are implemented via + # `annotated_types.Gt`/`Ge` metadata; the package has no published Sphinx + # inventory to link against. + ('py:class', 'annotated_types.Gt'), + ('py:class', 'annotated_types.Ge'), + # `RefinementParameter`'s auto-generated "alias of Annotated[...]" line + # (see its `#:` doc-comment in schema.py) spells out its real + # `PlainSerializer(func=, ...)` metadata; Sphinx's stringifier + # renders the lambda's qualname fragment as its own bogus xref target. + ('py:class', 'lambda'), ] + +def _resolve_type_alias_as_data(app, env, node, contnode): + """Fall back to a ``py:obj``-style lookup for unresolved ``py:class`` refs. + + Type-hint rendering always emits a ``:py:class:`` xref for any bare + identifier (see ``sphinx.domains.python._annotations.parse_reftarget``), + even when the identifier is actually a module-level type alias documented + as ``py:data`` (e.g. ``RefinementParameter = Annotated[...]``). The + Python domain's ``class`` role only searches ``class``/``exception`` + objtypes, so such a ref can never resolve as-is -- regardless of how the + alias itself is documented. Retry it as an ``obj`` lookup, which every + objtype (data, type, attribute, ...) satisfies. + """ + if node.get('refdomain') != 'py' or node.get('reftype') not in {'class', 'obj'}: + return None + py_domain = env.get_domain('py') + return py_domain.resolve_xref( + env, node['refdoc'], app.builder, 'obj', node['reftarget'], node, contnode + ) + + +def setup(app): + app.connect('missing-reference', _resolve_type_alias_as_data) + # MyST parser settings for Markdown support myst_enable_extensions = [ "deflist", # Definition lists @@ -114,11 +135,15 @@ 'member-order': 'bysource', 'special-members': '__init__', 'undoc-members': True, - 'exclude-members': '__weakref__' + # model_config is pydantic's internal ConfigDict boilerplate (identical on + # every model, not part of the recipe schema) -- excluding it avoids ~24 + # unresolvable `py:class reference target not found: ConfigDict` warnings + # (pydantic doesn't publish ConfigDict in its intersphinx inventory). + 'exclude-members': '__weakref__,model_config', } # Type hints configuration -autodoc_typehints = 'description' +autodoc_typehints = 'signature' autodoc_type_aliases = { 'RefinementParameter': 'powderline.schema.RefinementParameter', } diff --git a/docs/known_issues.md b/docs/known_issues.md index d16eb18..9945d38 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -203,24 +203,69 @@ so this *did* already fail the build) printed **266 warnings** **Evidence.** Reproduced by `pixi run docs-clean && pixi run docs`. -**Decision.** Fixed: -- added `docs/_static/.gitkeep` so the configured `html_static_path` exists; -- reworded the `RecipeModel` docstring to use a proper - `.. code-block:: javascript` directive and double-backtick literals - instead of markdown fences/single-backticks; -- relabeled the illustrative, comment-bearing ```json fences as - ```javascript (tolerant of `//` comments and `...`) in `DEVELOPMENT.md` - and `TROUBLESHOOTING.md`; -- fixed a handful of docstrings in `kicker.py` whose `Returns:` sections - (e.g. `is_template_file`, `extract_refined_params_from_project`, - `calculate_cell_esds_from_A_matrix`, `_extract_fit_profile`) were - misparsed by Napoleon as bogus `name (type):` pairs; - `docs/conf.py` `nitpick_ignore_regex`/`nitpick_ignore` for the - unresolvable pydantic-`PlainSerializer` and `pandas.core.frame.DataFrame` - noise; fixed the `pydandtic` intersphinx key typo; set +**Decision.** Fixed. Most of the ~230 pydantic-internals warnings were traced +to a real, fixable root cause rather than papered over: +- `RefinementParameter = Annotated[tuple[...], PlainSerializer(lambda ...)]` + fields were expanding to their full runtime type in generated docs because + `schema.py` lacked `from __future__ import annotations` (PEP 563); without + it, autodoc evaluates each field's annotation back to the real + `Annotated[..., PlainSerializer(...)]` object instead of keeping the + written `RefinementParameter` name. Adding the import keeps annotations + as their literal source text, so every field now renders (and links) as + the clean `RefinementParameter` alias. +- `autodoc_typehints` was set to `'description'`, which routes type info + through `sphinx.ext.autodoc.typehints.record_typehints` — a path that + re-stringifies each annotation independently of `autodoc_type_aliases` and + hands the result to the Python domain's `make_xrefs` helper to turn into + cross-references. This is a known, kindly-acknowledged rough edge in + Sphinx itself, not a pydantic quirk: + [sphinx-doc/sphinx#9641](https://github.com/sphinx-doc/sphinx/issues/9641) + ("`make_xrefs` should be consistent with `_parse_annotation`"). A Sphinx + maintainer confirmed the inconsistency in the thread, and explained the + tradeoff behind it: `make_xrefs` still has to support old-style, pre-typing + narrative `:type:` text (e.g. `"int or float"`), which isn't valid Python + and can't go through the same `ast.parse()`-based path (`_parse_annotation`, + used for real signatures) that degrades gracefully instead of splitting + unexpected input apart. That legacy-compatibility split is exactly what + trips up comma-bearing `Annotated` metadata like + `PlainSerializer(func=, return_type=, when_used=)`, turning it into several + unresolvable sub-references. It's an open, unscheduled enhancement rather + than a regression, so we've worked around it locally: switching to + Sphinx's own default, `'signature'`, sidesteps `make_xrefs` for our case by + rendering types inline in the signature via `_parse_annotation` instead. +- A `missing-reference` hook in `docs/conf.py` retries any unresolved + `py:class` type-hint reference as a `py:obj` lookup: type-hint rendering + always emits a `class`-role xref for bare identifiers, but a type alias + like `RefinementParameter` is documented as `py:data`, which the `class` + role's objtype search can never match — regardless of aliasing. `obj` + matches every objtype, so this is a legitimate, narrowly-scoped fallback + (not a suppression) and makes every `RefinementParameter` field a real + hyperlink to its `#:`-documented definition. +- `model_config` (identical `ConfigDict(...)` boilerplate on every model) + is now excluded from `autodoc_default_options`, removing ~24 warnings for + an attribute that isn't part of the public schema anyway. +- also: added `docs/_static/.gitkeep` (and un-ignored `docs/_static/` in + `.gitignore`) so the configured `html_static_path` exists; reworded the + `RecipeModel` docstring to use a proper `.. code-block:: javascript` + directive and double-backtick literals instead of markdown + fences/single-backticks; relabeled the illustrative, comment-bearing + ```json fences as ```javascript (tolerant of `//` comments and `...`) in + `DEVELOPMENT.md`/`TROUBLESHOOTING.md`; fixed a handful of `kicker.py` + docstrings whose `Returns:` sections (e.g. `is_template_file`, + `extract_refined_params_from_project`, `calculate_cell_esds_from_A_matrix`, + `_extract_fit_profile`) were misparsed by Napoleon as bogus `name (type):` + pairs; fixed the `pydandtic` intersphinx key typo; set `myst_heading_anchors = 4` so `cross-platform-guide.md`'s TOC anchors - resolve; and promoted `known_issues.md`'s `### KI-NN` headers to `##` - (the file has no other H2, so H1→H3 was a level skip). + resolve; and promoted `known_issues.md`'s `### KI-NN` headers to `##` (the + file had no other H2, so H1→H3 was a level skip). + +What's left is a 4-entry `nitpick_ignore` for targets that genuinely aren't +documented anywhere Sphinx can link to: `pandas.core.frame.DataFrame` +(pandas' intersphinx inventory only indexes the public `pandas.DataFrame` +path), `annotated_types.Gt`/`Ge` (the package publishes no Sphinx inventory), +and the literal `lambda` fragment inside `RefinementParameter`'s +auto-generated "alias of ..." line (which, accurately, spells out its real +`PlainSerializer(func=, ...)` metadata). **Revisit.** Closed; kept for history. diff --git a/src/powderline/schema.py b/src/powderline/schema.py index 12ef718..caa1b0c 100644 --- a/src/powderline/schema.py +++ b/src/powderline/schema.py @@ -12,6 +12,8 @@ See docs/SCHEMA_HISTORY.md. """ +from __future__ import annotations + from typing import Annotated, Any, Literal from typing_extensions import Self from pathlib import Path @@ -27,6 +29,9 @@ # Type alias for refinement parameter format: [value, refine_flag, min, max] # Using tuple to preserve types at each position +#: Format for a single refinement parameter, ``[value, refine_flag, min, max]``. +#: Modeled as a fixed 4-tuple (rather than a list) so each position keeps its +#: own type; serialized back to a JSON list via the attached ``PlainSerializer``. RefinementParameter = Annotated[ tuple[float | None, bool | None, float | None, float | None], PlainSerializer(lambda x: list(x), return_type=list, when_used='json') From b712e88a9329cbd0fdf1fc3475048c431c39a07c Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 10 Sep 2026 22:40:42 -0400 Subject: [PATCH 2/9] CI: add tests Assisted-by: opencode:claude-sonnet-5 --- .github/workflows/ci.yaml | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9c7d593 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: ['main'] + pull_request: + workflow_dispatch: + +# Least-privilege default: neither job here needs to write anything. +permissions: + contents: read + +# Cancel superseded runs on the same branch/PR to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + # Run on PRs (always under the upstream org's quota), on direct pushes to + # the upstream repo, and on manual dispatch. Skip push events from forks + # (no upstream PR yet) and skip same-repo PR runs that duplicate the + # branch-push run. + if: >- + github.repository == 'NSLS2/PowderLine' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: prefix-dev/setup-pixi@a0af7a228712d6121d37aba47adf55c1332c9c2e # v0.9.4 + with: + pixi-version: v0.74.0 + - name: Run tests + run: pixi run test + + docs: + # Build-only check that the docs still compile warning-free + # (`pixi run docs` uses `sphinx-build -W`, so any warning fails this + # job); the actual GitHub Pages deploy lives in docs.yml and only runs + # on pushes to main. Same fork/dedup policy as `test` above. + if: >- + github.repository == 'NSLS2/PowderLine' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: prefix-dev/setup-pixi@a0af7a228712d6121d37aba47adf55c1332c9c2e # v0.9.4 + with: + pixi-version: v0.74.0 + - name: Build docs + run: pixi run docs From 52532fdfe76984bd527d3954867a11015441c1c0 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 11 Sep 2026 09:02:16 -0400 Subject: [PATCH 3/9] CI: tweak launch rules Robot B is fixing robot A's work. It makes sense to allow the all-in-repo workflow. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yaml | 9 ++------- docs/conf.py | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c7d593..14c1f58 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,13 +21,8 @@ defaults: jobs: test: - # Run on PRs (always under the upstream org's quota), on direct pushes to - # the upstream repo, and on manual dispatch. Skip push events from forks - # (no upstream PR yet) and skip same-repo PR runs that duplicate the - # branch-push run. - if: >- - github.repository == 'NSLS2/PowderLine' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) + # Run on pull requests, direct pushes to main, and manual dispatch in the upstream repository. + if: github.repository == 'NSLS2/PowderLine' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/docs/conf.py b/docs/conf.py index 965554f..2874cf3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,7 +62,7 @@ # Treat missing cross-reference targets as warnings (not silently ignored) nitpicky = True -# cases with out a link target +# cases without a link target nitpick_ignore = [ # pandas' public docs index `pandas.DataFrame`, but runtime type hints # resolve to its internal module path; the pandas intersphinx inventory From c736aa6c5227c091478e2afdbcad87e014ce102d Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:10:39 -0400 Subject: [PATCH 4/9] Update CI workflow for docs job conditions Clarified conditions for running docs job in CI. --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 14c1f58..3997c5d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,9 @@ jobs: # Build-only check that the docs still compile warning-free # (`pixi run docs` uses `sphinx-build -W`, so any warning fails this # job); the actual GitHub Pages deploy lives in docs.yml and only runs - # on pushes to main. Same fork/dedup policy as `test` above. + # on pushes to main. For pull requests we intentionally only run this for + # external forks: same-repository PRs are not the normal contribution path + # and would otherwise duplicate CI for the upstream branch workflow. if: >- github.repository == 'NSLS2/PowderLine' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) From 63204434bfd2766ab9df6e85d154f2a21fa53cf5 Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:04:45 -0400 Subject: [PATCH 5/9] Implement fallback for unresolved py:class references Add workaround for Sphinx type-hint rendering of type aliases. --- docs/conf.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 2874cf3..c7c41aa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -81,8 +81,19 @@ ] +# Sphinx's type-hint renderer emits ``py:class`` references for bare names in +# annotations, even when the target is a module-level type alias documented as +# ``py:data``. Keep this workaround explicit so misspelled or wrong-role class +# references still warn under ``nitpicky = True``. +_TYPE_ALIAS_OBJ_FALLBACK_TARGETS = { + 'RefinementParameter', + 'powderline.schema.RefinementParameter', +} + + def _resolve_type_alias_as_data(app, env, node, contnode): """Fall back to a ``py:obj``-style lookup for unresolved ``py:class`` refs. + Resolve known type aliases documented as Python data objects. Type-hint rendering always emits a ``:py:class:`` xref for any bare identifier (see ``sphinx.domains.python._annotations.parse_reftarget``), @@ -92,13 +103,24 @@ def _resolve_type_alias_as_data(app, env, node, contnode): objtypes, so such a ref can never resolve as-is -- regardless of how the alias itself is documented. Retry it as an ``obj`` lookup, which every objtype (data, type, attribute, ...) satisfies. + ``RefinementParameter`` is a module-level ``Annotated[...]`` alias. Sphinx + renders references to it from type annotations as ``py:class`` links, but + the Python domain's ``class`` role only searches class/exception objects. + Retry just this explicit alias set as ``py:obj`` so it can link to its + ``py:data`` documentation while preserving warnings for all other + unresolved class references. """ if node.get('refdomain') != 'py' or node.get('reftype') not in {'class', 'obj'}: + if ( + node.get('refdomain') != 'py' + or node.get('reftype') != 'class' + or node.get('reftarget') not in _TYPE_ALIAS_OBJ_FALLBACK_TARGETS + ): return None + py_domain = env.get_domain('py') return py_domain.resolve_xref( env, node['refdoc'], app.builder, 'obj', node['reftarget'], node, contnode - ) def setup(app): From 7d0c2f37b485aa5a1a0a65577f1f5c1ac1c6282f Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:10:11 -0400 Subject: [PATCH 6/9] Update known issues with missing-reference hook details Clarified behavior of the `missing-reference` hook in `docs/conf.py` regarding `RefinementParameter` references and type-hint rendering. Updated documentation to improve hyperlinking for known aliases and reduce warnings. --- docs/known_issues.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/known_issues.md b/docs/known_issues.md index 9945d38..c61da50 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -236,11 +236,19 @@ to a real, fixable root cause rather than papered over: - A `missing-reference` hook in `docs/conf.py` retries any unresolved `py:class` type-hint reference as a `py:obj` lookup: type-hint rendering always emits a `class`-role xref for bare identifiers, but a type alias - like `RefinementParameter` is documented as `py:data`, which the `class` - role's objtype search can never match — regardless of aliasing. `obj` + like `RefinementParameter` is documented as `py:data`, which the + `class` role's objtype search can never match — regardless of aliasing. `obj` matches every objtype, so this is a legitimate, narrowly-scoped fallback (not a suppression) and makes every `RefinementParameter` field a real hyperlink to its `#:`-documented definition. +- A `missing-reference` hook in `docs/conf.py` retries unresolved `py:class` + references as `py:obj` only for the explicit + `RefinementParameter`/`powderline.schema.RefinementParameter` alias targets. + Type-hint rendering emits a `class`-role xref for the alias, but the alias is + documented as `py:data`, which the `class` role's objtype search cannot + match. Retrying only this known alias set as `obj` makes each + `RefinementParameter` field a real hyperlink while preserving nitpicky + warnings for genuine missing or wrong-role class references. - `model_config` (identical `ConfigDict(...)` boilerplate on every model) is now excluded from `autodoc_default_options`, removing ~24 warnings for an attribute that isn't part of the public schema anyway. From 1232a94aeed95a313dc6c5450a19d9424b2c8cc4 Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:18:37 -0400 Subject: [PATCH 7/9] Refactor condition for unresolved class references Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index c7c41aa..445ea75 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,6 @@ def _resolve_type_alias_as_data(app, env, node, contnode): ``py:data`` documentation while preserving warnings for all other unresolved class references. """ - if node.get('refdomain') != 'py' or node.get('reftype') not in {'class', 'obj'}: if ( node.get('refdomain') != 'py' or node.get('reftype') != 'class' From b2f90daa5e84103c5a68775fa54b356277bd97a3 Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:26:10 -0400 Subject: [PATCH 8/9] Fix missing closed paranthesis --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 445ea75..2526996 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -119,7 +119,7 @@ def _resolve_type_alias_as_data(app, env, node, contnode): py_domain = env.get_domain('py') return py_domain.resolve_xref( - env, node['refdoc'], app.builder, 'obj', node['reftarget'], node, contnode + env, node['refdoc'], app.builder, 'obj', node['reftarget'], node, contnode) def setup(app): From d22f279f15c2036ab57f540a9c8ff50729620af8 Mon Sep 17 00:00:00 2001 From: Adam Corrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:28:10 -0400 Subject: [PATCH 9/9] Remove stale explanation of the `missing-reference` hook in `docs/conf.py` --- docs/known_issues.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/known_issues.md b/docs/known_issues.md index c61da50..08e103e 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -233,14 +233,6 @@ to a real, fixable root cause rather than papered over: than a regression, so we've worked around it locally: switching to Sphinx's own default, `'signature'`, sidesteps `make_xrefs` for our case by rendering types inline in the signature via `_parse_annotation` instead. -- A `missing-reference` hook in `docs/conf.py` retries any unresolved - `py:class` type-hint reference as a `py:obj` lookup: type-hint rendering - always emits a `class`-role xref for bare identifiers, but a type alias - like `RefinementParameter` is documented as `py:data`, which the - `class` role's objtype search can never match — regardless of aliasing. `obj` - matches every objtype, so this is a legitimate, narrowly-scoped fallback - (not a suppression) and makes every `RefinementParameter` field a real - hyperlink to its `#:`-documented definition. - A `missing-reference` hook in `docs/conf.py` retries unresolved `py:class` references as `py:obj` only for the explicit `RefinementParameter`/`powderline.schema.RefinementParameter` alias targets.