Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 65 additions & 19 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<lambda>, ...)` 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
Expand Down Expand Up @@ -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',
}
79 changes: 62 additions & 17 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<lambda>, ...)` metadata).

**Revisit.** Closed; kept for history.

Expand Down
5 changes: 5 additions & 0 deletions src/powderline/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down
Loading