diff --git a/.gitignore b/.gitignore index 22e2be679..fdfa5dae5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,12 @@ build/ # MkDocs docs/site/ -# Generated docs-serving copy of the canonical Three.js (synced from -# src/ by `pixi run docs-sync-vendored-js`; the source of truth is src/) +# Generated docs-serving copies of the canonical JS runtimes and the +# shared figure loader (synced from src/ by `pixi run +# docs-sync-vendored-js`; the source of truth is src/) docs/docs/assets/javascripts/vendor/threejs/ +docs/docs/assets/javascripts/vendor/plotly/ +docs/docs/assets/javascripts/ed-figures.js # Jupyter Notebooks .ipynb_checkpoints diff --git a/.prettierignore b/.prettierignore index 32b5970dc..b856d3cd3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,10 +27,14 @@ node_modules # Vendored snapshots src/easydiffraction/display/structure/renderers/vendor/ +src/easydiffraction/display/plotters/vendor/ src/easydiffraction/report/templates/html/vendor/ src/easydiffraction/report/templates/tex/styles/ src/easydiffraction/utils/_vendored/jupyter_dark_detect/ +# Figure loader served verbatim (same as its docs/docs/assets/ origin) +src/easydiffraction/display/plotters/assets/ + # Tox .tox diff --git a/docs/dev/adrs/accepted/lint-rule-exceptions.md b/docs/dev/adrs/accepted/lint-rule-exceptions.md new file mode 100644 index 000000000..346a9cf7a --- /dev/null +++ b/docs/dev/adrs/accepted/lint-rule-exceptions.md @@ -0,0 +1,79 @@ +# ADR: Lint Rule Scope and Test-File Exceptions + +## Status + +Accepted. + +## Date + +2026-06-08 + +## Group + +Quality. + +## Context + +A full audit of ruff's disabled rules (merged in #194) adopted a +low-risk subset and recorded which remaining suppressions are +deliberate. Three of those suppressions are standing policy rather than +temporary noise, and the audit flagged each as needing a recorded +decision because it either scopes or extends the +[`lint-complexity-thresholds.md`](lint-complexity-thresholds.md) ADR +(which treats PLR complexity limits as guardrails that must not be +silenced): + +- The PLR complexity rules `PLR0913`, `PLR0914`, `PLR0915`, and + `PLR0917` are ignored for `tests/**`. +- `N812` (lowercase import alias) is ignored for `tests/**`. +- The flake8-builtins `A` family is not enabled at all, so CIF-aligned + field names such as `id` and `type` do not trip builtin-shadowing + rules. + +The audit document itself was a one-time roadmap and is not retained; +this ADR records the durable decisions it surfaced. + +## Decision + +1. **Test-file complexity exception.** `PLR0913`, `PLR0914`, `PLR0915`, + and `PLR0917` stay ignored under `tests/**` via + `[tool.ruff.lint.per-file-ignores]`. This is a deliberate, scoped + exception to `lint-complexity-thresholds.md`: test bodies + legitimately accumulate many arguments, locals, and statements + (fixtures, parametrisation, arrange-act-assert) where the complexity + is not a maintainability signal. Production code under `src/**` + remains fully governed by `lint-complexity-thresholds.md` — the + guardrail is not relaxed there, and `# noqa` / threshold raises + remain disallowed in `src/**`. + +2. **Test-file import-alias exception.** `N812` stays ignored under + `tests/**` so tests may import a module-under-test with a lowercase + alias (the `MUT` / `mut` idiom) without renaming convention churn. + +3. **CIF-aligned builtin names.** The flake8-builtins `A` family stays + disabled project-wide so categories can use the CIF-aligned field + names `id` and `type` (mandated by IUCr CIF tag alignment) without + builtin-shadowing warnings. Renaming these would break CIF + correspondence; the lint cost is not worth the divergence. + +These exceptions are the complete set of standing PLR/naming/builtin +suppressions. Any further permanent suppression that conflicts with or +extends `lint-complexity-thresholds.md` needs its own recorded decision +before adoption. + +## Consequences + +### Positive + +- The standing test-file and CIF-naming suppressions are documented + decisions rather than unexplained `pyproject.toml` entries. +- `lint-complexity-thresholds.md` keeps its full force over `src/**`; + the scope of the relaxation is explicit and bounded to `tests/**`. + +### Trade-offs + +- Test code can grow more complex without lint feedback; reviewers carry + that judgement instead of the linter. +- The `id` / `type` field names continue to shadow builtins by design, + so contributors must keep CIF-alignment context in mind when reading + those categories. diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 4733588fb..c282806d3 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -36,6 +36,7 @@ folders. | Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) | | Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | | Experiment model | Accepted | Automatic Line-Segment Background Estimation | Detects line-segment background control points from the measured pattern, peak-insensitive and editable. | [`background-auto-estimate.md`](accepted/background-auto-estimate.md) | +| Experiment model | Suggestion | Calculation Without Measured Data | Adds a writable `data_range` category so a structure-only experiment is calculable and plottable without loaded data. | [`calculation-without-measured-data.md`](suggestions/calculation-without-measured-data.md) | | Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | | Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | | Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | @@ -44,6 +45,7 @@ folders. | Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | | Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) | | Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | +| Quality | Accepted | Lint Rule Scope and Test-File Exceptions | Records the standing tests/\*\* PLR/N812 ignores and CIF-aligned `id`/`type` builtin exception from the lint audit. | [`lint-rule-exceptions.md`](accepted/lint-rule-exceptions.md) | | Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | | Quality | Accepted | Test Suite and Validation Strategy | Strict test layers, cost tiers, coverage/codecov policy, cross-engine verification docs, and a nightly validation harness. | [`test-suite-and-validation.md`](accepted/test-suite-and-validation.md) | | Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | diff --git a/docs/dev/adrs/suggestions/calculation-without-measured-data.md b/docs/dev/adrs/suggestions/calculation-without-measured-data.md new file mode 100644 index 000000000..a58b11495 --- /dev/null +++ b/docs/dev/adrs/suggestions/calculation-without-measured-data.md @@ -0,0 +1,183 @@ +# ADR: Calculation Without Measured Data + +## Status + +Proposed. + +## Date + +2026-06-06 + +## Group + +Experiment model. + +## Context + +An experiment built with `ExperimentFactory.from_scratch(...)` has no +data points. The x-grid that every calculation runs on — the set of 2θ +or time-of-flight values — lives inside `experiment.data` and is only +ever populated by loading measured data +(`_create_items_set_xcoord_and_id`). With no grid, a calculation cannot +run: + +- cryspy derives its scan range from `experiment.data.x.min()/max()` + (`_cif_range_section`), which raises + `ValueError: zero-size array to reduction operation minimum` on the + empty array. +- crysfml passes `experiment.data.x.tolist()` as the scan, i.e. an empty + scan. + +So `project.display.pattern(expt_name=...)` fails for a structure that +has never been measured, even though everything needed to _calculate_ a +pattern (structure, instrument, peak shape, background) is present. + +Scientists routinely want to **simulate** a pattern — or, for a single +crystal, per-reflection F² — from a structural model alone: choose a +range, calculate, and view the calculated curve with its background and +Bragg reflections, with no measurement to load or invent. This is also +how a saved project with no measured block should restore from the CLI. + +Two existing decisions frame the solution: + +- [Unified Pattern View](../accepted/pattern-display-unification.md) + already establishes that `pattern()` renders whatever the project + state supports. "Only calculated data is available" should simply be + one more supported state; today the display gates instead require + measured data before background or Bragg can appear. +- The IUCr powder and core dictionaries already model an evenly-spaced + scan **by range**: `_pd_meas.2theta_range_{min,max,inc}` and + `_pd_proc.2theta_range_{min,max,inc}` are defined to be used "in place + of the `2theta_scan` values" for constant-step data, and + `_refln.sin_theta_over_lambda` / `_refln.d_spacing` are + instrument-independent reciprocal coordinates shared by powder and + single-crystal data. + +## Decision + +Introduce a `data_range` category that defines the reciprocal-space +region (and, for powder, the profile step) to calculate over, and let +the calculation and display paths fall back to it whenever no measured +scan exists. + +1. **New category, type-determined.** `data_range` is a flat sibling of + `data`, with per-type concrete classes created through a factory + (`CwlDataRange`, `TofDataRange`, `ScDataRange`) and exposed uniformly + as `experiment.data_range`. It is fixed by the experiment type, so it + has **no** `type` selector — the same treatment + [Switchable Category API](../accepted/switchable-category-api.md) + prescribes for fixed, single-type categories, and the same pattern + `instrument` already uses for its per-beam-mode classes + ([Immutable Experiment Type](../accepted/immutable-experiment-type.md)). + +2. **Stored truth is the natural input axis (writable).** The values a + user sets and that serialise to CIF are the experiment's own axis: + - CWL powder: `two_theta_{min,max,inc}` + - TOF powder: `time_of_flight_{min,max,inc}` + - Single crystal: `sin_theta_over_lambda_{min,max}` (no `inc`) + +3. **sinθ/λ is the derived shared currency.** Every type also exposes + `sin_theta_over_lambda` and `d_spacing` (related by + `sinθ/λ = 1/(2·d)`, instrument-free), plus `x_{min,max,step}` aliases + onto the active axis (mirroring the existing plotting x-array alias). + Generic code — plotting and reflection generation — reads sinθ/λ. For + CWL and TOF the sinθ/λ and d views derive from the stored axis + through the instrument (λ for 2θ, DIFC/DIFA for TOF), so + **recalibration keeps the stored axis window fixed and re-derives + sinθ/λ**. For single crystal there is no measurement axis, so sinθ/λ + is itself the stored truth. + +4. **Bounds bound generation; step is powder-only.** `min`/`max` (as + sinθ/λ) bound reflection generation — powder through the engine's + 2θ/TOF range, single crystal through cryspy + `Crystal.calc_hkl(sthovl_max)`, which enumerates hkl with the space + group's systematic absences applied. `inc` is the profile point + spacing on the measurement axis and exists only for powder; a single + crystal has bounds but no step. + +5. **Writable, guarded by measurement.** Following + [Guarded Public Properties](../accepted/guarded-public-properties.md), + the `data_range` axis attributes are writable public properties. The + setter raises when a measured scan is present, because then the range + is an _observed_ property of the data rather than an input; the + getter returns the measured-derived range in that case (subsuming + today's `experiment.measured_range`) and the stored or default range + otherwise. Loaders and project restore seed values through a private + `_set_`. + +6. **Defaults authored in d-spacing.** Default ranges are stored in + d-spacing and projected onto each axis through the instrument, so a + `from_scratch` experiment is calculable with no manual setup and the + TOF default — meaningless in absolute µs without calibration — stays + well defined. + +7. **No `simulate()` method.** Accessing or plotting `data` (powder) or + `refln` (single crystal) with no measured scan builds the grid or + reflection list from `data_range` and calculates on it. The grid is + model state, not a one-shot action, so it is expressed as a + serialisable category rather than a method call. + +8. **Display extends the unified view.** Building on + [Unified Pattern View](../accepted/pattern-display-unification.md), + `background` and `bragg` become available with calculated-only data — + the measured-data requirement in their availability gates is dropped. + "No measurement" is represented as _absent_ intensities (not a + zero-filled array), so no phantom measured curve or residual is + drawn. A calc-only powder view is the calculated curve plus + background on the main panel and a Bragg row; a calc-only + single-crystal view shows per-reflection calculated intensities. + +9. **CIF mapping.** CWL bounds reuse the standard + `_pd_meas.2theta_range_{min,max,inc}`. TOF, single-crystal, and the + sinθ/λ–d bounds have no standard range tag, so custom tags are chosen + in line with + [IUCr CIF Tag Alignment](../accepted/iucr-cif-tag-alignment.md) and + [Python and CIF Category Correspondence](../accepted/python-cif-category-correspondence.md). + +## Consequences + +- A structure-only experiment can be calculated and plotted with no data + loaded: set `data_range` (or accept the defaults), then call + `project.display.pattern(...)`. The original failure is resolved. +- One uniform `experiment.data_range` spans all experiment types; + generic display and calculator code read the shared sinθ/λ view and + need not branch on beam mode. +- `experiment.measured_range` is subsumed by the derived getter on + `data_range`. +- The project is in beta, so this adds the category with no + compatibility shim; tutorials and tests adopt it directly. +- New code spans a `data_range` category, factory, and per-type classes; + calc-on-access grid and reflection generation; single-crystal hkl + generation via `calc_hkl`; and the display-gate relaxation plus a + calc-only single-crystal view. + +## Alternatives Considered + +- **A single generic `data_range.min/max/inc`.** Rejected: a bare `min` + is not self-explaining, because even for CWL a range may be thought of + in 2θ, d-spacing, or sinθ/λ. Axis-named attributes that cross-convert + read better and still expose the shared sinθ/λ view for generic code. +- **A custom `pd_calc.2theta_range_*`.** Rejected: `pd_calc` has no + range in the dictionary (it is intensities, and reuses the meas/proc + point grid), so a custom calc range would lose interoperability with + no clear benefit over the standard `pd_meas` range plus the universal + `refln` reciprocal coordinates. +- **An input/output split** — a writable "requested range" input plus a + read-only derived output — mirroring + [Minimizer Input/Output Split](../accepted/minimizer-input-output-split.md). + Rejected as heavier than needed here; a single guarded writable + property covers both roles. +- **A `simulate(x_min, x_max, x_step)` method.** Rejected: the range is + persistent, restorable model state, which a method call is not. +- **A range read directly by the calculators, with no data points.** + Rejected: it is more invasive (both engines plus plotting) and breaks + the invariant that the data points define the grid. + +## Deferred Work + +- Single-crystal reflection-generation wiring (the cryspy `calc_hkl` + path and validation of absence handling); crysfml single-crystal + structure-factor support is expected soon and adopts the same path. +- Final custom CIF tag names for the TOF, sinθ/λ, and d-spacing bounds. +- Concrete default numeric ranges and steps per experiment type. +- The calc-only single-crystal plot specifics. diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index 42e37755c..052a4a254 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -438,6 +438,61 @@ mapping and the hardcoded defaults need verification. --- +## 116. 🟡 cryspy Diverges on TOF Jorgensen–Von Dreele Lorentzian + +**Type:** Correctness + +For time-of-flight powder data using the Jorgensen–Von Dreele peak +profile, the `cryspy` backend diverges from FullProf and `crysfml` +whenever the Lorentzian term (`broad_lorentz_gamma_*`) is non-zero. On +the Si Verification reference case the profile difference reaches ≈22% +with an integrated-intensity ratio ≈0.72–0.76, while `crysfml` matches +FullProf to <1%. When the Lorentzian term is zero (NaCaAlF) `cryspy` +agrees to <1%, which localises the problem to the cryspy translation of +the pseudo-Voigt (Gaussian ⊗ Lorentzian) mixing for TOF. + +**Fix:** verify how `broad_lorentz_gamma_*` is passed to cryspy for the +`jorgensen-von-dreele` profile and reconcile the convention with +crysfml/FullProf. + +**Visible on:** the Si TOF Verification page (`pd-neut-tof_jvd_si`), +whose closeness table flags the `cryspy` rows in red — reported via a +non-raising agreement check, not enforced, so CI stays green. +Re-introduce a strict check, or skip the page via +`docs/docs/verification/ci_skip.txt`, once work on the cryspy backend +begins. + +**Depends on:** nothing. + +--- + +## 117. 🟡 Add SyCos/SySin Systematic Peak-Position Corrections + +**Type:** Feature / Experiment model + +FullProf models systematic peak-position aberrations with `SyCos` +(sample displacement) and `SySin` (transparency), shifting peaks as a +function of angle on top of the `Zero` offset. EasyDiffraction has no +category for these, so it cannot reproduce datasets that use them. The +cryspy side is implemented in +[cryspy PR #46](https://github.com/ikibalin/cryspy/pull/46) (see +[issue #38](https://github.com/ikibalin/cryspy/issues/38)); the +EasyDiffraction side — an instrument-category parameter pair plus the +calculator wiring — is still to do. + +A prepared verification page, +`docs/docs/verification/pd-neut-cwl_tch-fcj_lab6.py`, uses the issue #38 +LaB6 dataset and is skipped via `ci_skip.txt`. Finishing it also needs a +custom ¹¹B scattering length, the Thompson–Cox–Hastings profile, and a +FullProf-style polynomial background, which that dataset relies on. + +**Fix:** add `SyCos`/`SySin` to the CWL instrument category, pass them +to the calculators, then un-skip the LaB6 page. + +**Depends on:** nothing. + +--- + ## 22. 🟢 Check CrysPy Single-Crystal Instrument Mapping **Type:** Correctness @@ -1986,6 +2041,151 @@ a baseline-cleanup plan. --- +## 117. 🟢 Live-Notebook Plotly Delivery: Loader vs Native Mimetype + +**Type:** Display / Architecture + +Records the two viable strategies for rendering interactive Plotly +figures in live notebooks, so the trade-off is not re-litigated. See +[`plotting-docs-performance.md`](../adrs/accepted/plotting-docs-performance.md). + +**Background.** Live notebooks historically rendered via +`display(HTML(pio.to_html(..., include_plotlyjs='cdn')))`, which caused +an empty first plot after kernel restart (the CDN `' + ) + display(HTML(self._wrap_html_figure(fig, target_html) + script)) + + @classmethod + def _live_runtime_bootstrap_js(cls) -> str: + """ + Return one-time runtime + loader JavaScript for live notebooks. + + On the first call in a kernel session this returns the + self-hosted Plotly bundle and the shared ``ed-figures.js`` + loader as raw JavaScript (for a Javascript output); later calls + return an empty string. Running inline means the loader never + races an async runtime download. + + Returns + ------- + str + The bootstrap JavaScript, or ``''`` once already injected + this session. + """ + if cls._live_runtime_injected: + return '' + cls._live_runtime_injected = True + runtime = _packaged_asset(_PLOTLY_RUNTIME_ASSET) + loader = _packaged_asset(_FIGURE_LOADER_ASSET) + # The leading ';' guards against the runtime's last statement + # swallowing the loader IIFE through automatic semicolon rules. + return f'{runtime}\n;\n{loader}\n;\n' @staticmethod def _ed_theme_payload() -> dict: @@ -1836,17 +1916,14 @@ def _figure_height(fig: object) -> int: return DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT @classmethod - def _serialize_html_shared(cls, fig: object) -> str: + def _figure_spec_json(cls, fig: object) -> str: """ - Serialize a figure as a lazy SHARED-mode placeholder. + Serialize a figure to the JSON spec the loader renders. - Emits a skeleton plus the figure spec as ``application/json`` - for the shared ``ed-figures.js`` loader to render on demand. No - Plotly bundle or per-figure post-script is embedded; the runtime - loads once per page and the loader owns theme-sync, resize, and - legend. Bulk float64 arrays are downcast to float32 (visually - lossless, ~7 significant figures) to roughly halve the embedded - data. + Carries the trace data, layout, config, and the theme/legend + metadata the loader needs. Bulk float64 arrays are downcast to + float32 (visually lossless, ~7 significant figures) to roughly + halve the embedded data. Parameters ---------- @@ -1856,7 +1933,8 @@ def _serialize_html_shared(cls, fig: object) -> str: Returns ------- str - Placeholder HTML carrying the figure spec. + The figure spec as a JSON string, with ``<`` escaped so it + is safe inside a ``') == 1 # exactly one render script element + assert 'renderSpec' in first + assert 'Plotly' in first + + captured.clear() + plotter._show_figure(go.Figure()) + second = captured[0] + # Later figures reference the already-injected runtime, not re-embed. + assert 'renderSpec' in second + assert 'Plotly' not in second + assert len(second) < len(first) diff --git a/tools/bump_vendored_js.py b/tools/bump_vendored_js.py index b62ae11a4..974780155 100644 --- a/tools/bump_vendored_js.py +++ b/tools/bump_vendored_js.py @@ -81,7 +81,7 @@ class VendoredRuntime: name='Plotly.js (cartesian bundle)', package='plotly.js', version='3.5.0', - dest_dir=Path('docs/docs/assets/javascripts/vendor/plotly'), + dest_dir=Path('src/easydiffraction/display/plotters/vendor/plotly'), licence=( 'MIT — Copyright 2012-2026 Plotly, Inc. ' 'See `https://github.com/plotly/plotly.js/blob/master/LICENSE`.' diff --git a/tools/lint_rule_audit.py b/tools/lint_rule_audit.py index 67aaf34eb..f342a8f5b 100644 --- a/tools/lint_rule_audit.py +++ b/tools/lint_rule_audit.py @@ -2,9 +2,10 @@ # SPDX-License-Identifier: BSD-3-Clause """Regenerate the disabled-rule inventory for the lint-rule audit. -This helper reproduces the table in -``docs/dev/plans/lint-rule-audit.md`` without modifying any tracked -file. It layers the currently-disabled Ruff rules onto the *unmodified* +This helper reproduces the disabled-rule inventory behind +``docs/dev/adrs/accepted/lint-rule-exceptions.md`` without modifying any +tracked file. It layers the currently-disabled Ruff rules onto the +*unmodified* ``pyproject.toml`` at the command line, runs ``ruff check``, and prints a per-rule breakdown by source scope (``src``/``tests``/``tutorials``) and auto-fixability:: diff --git a/tools/sync_docs_vendored_js.py b/tools/sync_docs_vendored_js.py index ffd7ab207..785c3b582 100644 --- a/tools/sync_docs_vendored_js.py +++ b/tools/sync_docs_vendored_js.py @@ -1,16 +1,20 @@ """ -Sync the canonical vendored Three.js into the docs assets. +Sync the canonical JavaScript runtimes into the docs assets. MkDocs can only serve files under ``docs/docs``, so the canonical -Three.js snapshot — which ships in the wheel from ``src/`` — is copied -into ``docs/docs/assets/javascripts/vendor/threejs/`` for the site to -serve. That docs copy is generated (git-ignored); the single source of -truth is ``src/``. Plotly needs no sync: its docs-only bundle already -lives under ``docs/docs/assets``. +snapshots — which ship in the wheel from ``src/`` — are copied into +``docs/docs/assets/javascripts/`` for the site to serve. Those docs +copies are generated (git-ignored); the single source of truth is +``src/``. This covers: + +* Three.js (``vendor/threejs/``) — structure views, +* the Plotly cartesian bundle (``vendor/plotly/``) — interactive plots, +* ``ed-figures.js`` — the shared figure loader used by both the docs + site and live notebooks. Run automatically before ``mkdocs build``/``serve`` via the -``docs-sync-vendored-js`` pixi task; the asset names come from the same -pinned table as ``tools/bump_vendored_js.py``. +``docs-sync-vendored-js`` pixi task; the vendored asset names come from +the same pinned table as ``tools/bump_vendored_js.py``. """ from __future__ import annotations @@ -19,33 +23,62 @@ import sys from pathlib import Path +from bump_vendored_js import PLOTLY from bump_vendored_js import THREEJS _REPO_ROOT = Path(__file__).resolve().parent.parent -_DOCS_VENDOR_DIR = Path('docs/docs/assets/javascripts/vendor/threejs') +_DOCS_JS_DIR = Path('docs/docs/assets/javascripts') + +# The shared figure loader is project code (not a fetched third-party +# snapshot), so it lives outside the vendor tree. +_ED_FIGURES_SOURCE = Path('src/easydiffraction/display/plotters/assets/ed-figures.js') +_ED_FIGURES_DEST = _DOCS_JS_DIR / 'ed-figures.js' + + +def _copy(source: Path, dest: Path) -> bool: + """ + Copy one canonical file into the docs assets. + + Parameters + ---------- + source : Path + Repo-relative canonical source path. + dest : Path + Repo-relative docs destination path. + + Returns + ------- + bool + ``True`` on success; ``False`` if the source is missing. + """ + absolute_source = _REPO_ROOT / source + if not absolute_source.is_file(): + print(f'missing canonical source: {source}') + print('Run `pixi run vendor-update-js` first.') + return False + absolute_dest = _REPO_ROOT / dest + absolute_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(absolute_source, absolute_dest) + print(f' synced {dest}') + return True def sync() -> int: """ - Copy the canonical Three.js files into the docs assets. + Copy the canonical runtimes and loader into the docs assets. Returns ------- int - ``0`` on success; ``1`` if a canonical source file is missing. + ``0`` on success; ``1`` if any canonical source file is missing. """ - src_dir = _REPO_ROOT / THREEJS.dest_dir - dest_dir = _REPO_ROOT / _DOCS_VENDOR_DIR - dest_dir.mkdir(parents=True, exist_ok=True) - for asset in THREEJS.assets: - source = src_dir / asset.filename - if not source.is_file(): - print(f'missing canonical source: {THREEJS.dest_dir / asset.filename}') - print('Run `pixi run vendor-update-js` first.') - return 1 - shutil.copy2(source, dest_dir / asset.filename) - print(f' synced {_DOCS_VENDOR_DIR / asset.filename}') - return 0 + ok = True + for runtime in (THREEJS, PLOTLY): + docs_dir = _DOCS_JS_DIR / 'vendor' / runtime.dest_dir.name + for asset in runtime.assets: + ok = _copy(runtime.dest_dir / asset.filename, docs_dir / asset.filename) and ok + ok = _copy(_ED_FIGURES_SOURCE, _ED_FIGURES_DEST) and ok + return 0 if ok else 1 if __name__ == '__main__': diff --git a/tools/test_scripts.py b/tools/test_scripts.py index 12df2e6ba..f5d96c689 100644 --- a/tools/test_scripts.py +++ b/tools/test_scripts.py @@ -20,14 +20,31 @@ _repo_root = Path(__file__).resolve().parents[1] _src_root = _repo_root / 'src' +_CI_SKIP_FILE = _repo_root / 'docs' / 'docs' / 'verification' / 'ci_skip.txt' -# Discover tutorial and verification scripts, excluding checkpoint files. + +def _ci_skipped_stems(): + """Verification notebook stems to skip (shared with the nbmake conftest).""" + if not _CI_SKIP_FILE.is_file(): + return set() + stems = set() + for line in _CI_SKIP_FILE.read_text(encoding='utf-8').splitlines(): + entry = line.split('#', 1)[0].strip() + if entry: + stems.add(entry) + return stems + + +_CI_SKIP = _ci_skipped_stems() + +# Discover tutorial and verification scripts, excluding checkpoint files +# and pytest conftest modules. _SCRIPT_DIRS = ('docs/docs/tutorials', 'docs/docs/verification') TUTORIALS = [ p for directory in _SCRIPT_DIRS for p in Path(directory).rglob('*.py') - if '.ipynb_checkpoints' not in p.parts + if '.ipynb_checkpoints' not in p.parts and p.name != 'conftest.py' ] @@ -38,6 +55,9 @@ def test_script_runs(script_path: Path): Each script is run in the context of __main__ to mimic standalone execution. """ + if script_path.stem in _CI_SKIP: + pytest.skip(f'{script_path.stem} is skipped in CI (ci_skip.txt)') + env = os.environ.copy() if _src_root.exists(): existing = env.get('PYTHONPATH', '')