diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3997c5d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,56 @@ +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 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 + 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. 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) + 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 diff --git a/docs/conf.py b/docs/conf.py index c229316..2526996 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,27 +62,69 @@ # 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 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 + # 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'), ] + +# 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``), + 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. + ``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') != '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): + app.connect('missing-reference', _resolve_type_alias_as_data) + # MyST parser settings for Markdown support myst_enable_extensions = [ "deflist", # Definition lists @@ -114,11 +156,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..08e103e 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 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. +- 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')