Skip to content

Add prism_report pytest plugin (annotated spectra + DUT context) - #740

Open
tfcollins wants to merge 19 commits into
mainfrom
tfcollins/prism
Open

Add prism_report pytest plugin (annotated spectra + DUT context)#740
tfcollins wants to merge 19 commits into
mainfrom
tfcollins/prism

Conversation

@tfcollins

Copy link
Copy Markdown
Collaborator

Summary

Adds a new opt-in pytest plugin under test/plugins/prism_report/ that turns existing controlled-tone loopback tests into a richer test report:

  • Per-case annotated Plotly spectrum built from genalyzer Fourier Analysis (carriers, harmonics, IMD, image, DC, NSD; SFDR/SNR/SINAD/THD/NSD metrics card)
  • Run-level DUT contextboot.log (passive labgrid console capture), dmesg_pre.log / dmesg_post.log / dmesg_diff.log (SSH preferred, labgrid console fallback), iio_info.txt (binary or pylibiio walk fallback), run_meta.json
  • Dual-sink output — write a local directory and/or upload in-process to a Prism instance via a vendored copy of prism/scripts/_prism_client.py
  • Plugin is off by default; activated via --prism-report (or PRISM_REPORT=1)
  • Graceful degradation — if genalyzer isn't installed, render an unannotated spectrum; if labgrid is unreachable, skip boot.log; if prism is down, preserve the local export

5 existing loopback fixtures in dma_tests.py upgraded to emit the new richer payload through the existing pytest.data_log / do_html_log convention. Behavior when --prism-report is off is unchanged — legacy {"html": "..."} payloads remain tolerated.

Architecture

8 small modules, one job each: config.py, manifest.py, analyze.py, render.py, capture.py, upload.py, plugin.py (the only file that imports pytest), and a vendored _prism_client.py. Plugin registered via pytest_plugins in test/conftest.py (NOT a setuptools entry-point — the plugin lives in test/ and pyadi-iio's wheel correctly excludes that tree).

Spec: docs/superpowers/specs/2026-04-30-pyadi-prism-report-design.md
Plan: docs/superpowers/plans/2026-04-30-pyadi-prism-report-plan.md

Notable

  • Wheel surface unchanged (include = ["adi"]; exclude = ["test*"] preserved)
  • 47 new tests (46 unit/integration passing + 1 gated bench-loop test that skips without BENCH_PLUTO_URI)
  • Zero regressions in the existing 1,842 tests
  • New runtime deps gated behind the prism_report extras (plotly, pyyaml only)

Test plan

  • CI green on the existing test suite (no regressions)
  • pytest test/plugins/prism_report/ -v → 46 passed locally
  • pytest --prism-report --prism-out=/tmp/run-001 test/test_ad9364_p.py::test_ad9364_sfdr against simulated emu — spot-check that artifacts land in /tmp/run-001/
  • BENCH_PLUTO_URI=ip:192.168.2.1 pytest test/plugins/prism_report/tests/bench/ -m bench_pluto -v against a wired-up Pluto — verify SFDR thresholds in tests/bench/thresholds.yaml are reasonable
  • Stand up a Prism instance, run with --prism-url=..., verify run + per-case spectrum HTML appear in the Prism UI
  • Open one of the generated spectrum.html files in a browser — confirm carriers/harmonics annotations render correctly

Pure function `analyze(payload) -> AnalysisResult` that uses
genalyzer.simplified_beta when available and falls back to a plain
numpy FFT (without component annotations) otherwise. No I/O.

Adds Component / AnalysisResult dataclasses (consumed unchanged by
the upcoming render.py) and synthetic IQ fixtures used by tests.
Replace the scaffolded plugin.py with the fully wired version that
assembles Config, OutputDir, capture_session_pre/post, analyze,
render_spectrum, and upload into pytest hooks.

The plugin now:
- pytest_configure: builds Config, creates OutputDir, points JUnit XML
  into the run dir.
- pytest_sessionstart: runs capture_session_pre and writes iio_info,
  boot.log, and dmesg_pre artifacts.
- pytest_runtest_makereport: drains pytest.data_log per case, runs
  analyze + render_spectrum, writes spectrum.html + iq.npz +
  metrics.json under cases/<safe_id>/. Falls through gracefully on
  payload, analyze, or render errors.
- pytest_sessionfinish: runs capture_session_post, writes dmesg_post
  + dmesg_diff, finalizes the manifest, and (if --prism-url is set)
  invokes upload().

Side fixes:
- Config.from_pytest now uses parse_known_args so pytest's own flags
  (-q, -v, ...) on invocation_params.args don't blow up the prism
  parser.
- tests/conftest.py inner-pytester fixture now also injects
  -p test.plugins.prism_report.plugin so pytester runs see the
  plugin's addoptions.

End-to-end test populates pytest.data_log with synthetic IQ and
asserts manifest.json + spectrum.html (containing plotly) appear
in the out-dir. All 46 prism_report tests pass.
Round out the plugin README with the complete flag/env/default table,
a troubleshooting matrix, and the artifact-ownership table.

Also relocate `pytest_plugins = ["pytester"]` from the prism_report
self-tests' conftest to the top-level test/conftest.py: pytest 9.x rejects
pytest_plugins in non-rootdir conftests as a hard error during collection,
which broke `pytest --collect-only test/`. The pytester fixture override
stays in the inner conftest; only the registration moved.
@github-actions

github-actions Bot commented Apr 30, 2026

Copy link
Copy Markdown

Test Results

1 861 tests  +49     553 ✅ +45   4m 54s ⏱️ +31s
    1 suites ± 0   1 305 💤 + 1 
    1 files   ± 0       3 ❌ + 3 

For more details on these failures, see this check.

Results for commit 78bed86. ± Comparison against base commit ee07c9b.

♻️ This comment has been updated with latest results.

Real-prism upload was failing with HTTP 422 "metadata field required":
upload.py was building a hand-rolled multipart with field names
"project"/"run_name"/"tag.<k>"/"artifacts" that don't match what the
prism API expects (a single "metadata" JSON form field plus "junit" and
optional "archive" file parts). The prior fake-prism test only checked
body length, so the field-name drift slipped through.

- Replace the hand-rolled _multipart() construction with a delegated
  call to the vendored _prism_client.upload_run() which builds the
  correct payload shape.
- Tighten the fake handler in test_upload_fake_prism.py to actually
  parse the multipart body and require "metadata" + "junit" form
  fields (returns 422 otherwise, matching real prism).
- Round-trip test now asserts metadata.project_slug, metadata.name,
  metadata.tags, and that "archive" is sent.

Caught when running the bench loop against a live prism docker stack.
Pytest parametrize ids that include large dicts (e.g.
test_ad9364_sfdr's full param_set) sanitize to 400+ chars, exceeding
ext4's 255-byte limit on path components and crashing pytest with
INTERNALERROR: OSError [Errno 36] inside makereport.

Truncate to 200 bytes and append a 4-byte blake2b digest of the
original nodeid to keep collisions impossible. Test added: two huge
nodeids differing only in the last char must yield distinct dirs.

Caught when running test_ad9364_sfdr against a real Pluto.
…nvention

Prism's worker parses each archive basename as
`{suite}__{case}__{label}.{ext}` to attach artifacts to the right case.
Our local-export layout is `cases/<safe_id>/<filename>`, which prism's
parser saw as run-scoped, so per-case spectra were uploaded but never
attached to their cases.

In `_build_artifacts_zip`:
- Read the testsuite name from junit.xml.
- Read manifest.json to map safe_id -> case_nodeid -> JUnit case name
  (the part of the nodeid after `::`).
- Rewrite per-case archive entries to `{suite}__{case}__{filename}`.
- Run-level artifacts stay bare (run-scoped, unchanged).

Test now opens the uploaded archive and asserts the rewrite produces
`pytest__c__spectrum.html` for a per-case entry and a bare
`iio_info.txt` for a run-level entry.

Caught when running test_ad9364_sfdr against a live prism.
Previously each case got `spectrum.html` only. When prism's UI tried to
display it via <iframe>, MinIO returned the bytes with
`Content-Type: binary/octet-stream` + `X-Content-Type-Options: nosniff`,
so browsers refused to render the page (blank frame). Setting the
content-type at upload would fix that one case but the iframe approach
also fights with cross-origin cookies, mixed content, and host
isolation in remote-browser scenarios.

Better fit: ship the Plotly figure as JSON alongside the HTML. The host
UI (prism) fetches the JSON via a proxying API endpoint and renders it
with its own react-plotly.js stack — no iframe, no MinIO direct fetch,
no content-type sniffing, fully theme-integrated.

- `render.py`: extract `_build_spectrum_figure()` from `render_spectrum`
  so HTML and JSON share identical figure construction. New
  `render_spectrum_figure_json()` returns `fig.to_json()`.
- `plugin.py`: in the makereport hook, call both renderers and write
  `spectrum.html` + `spectrum.json` per case. The HTML stays for offline
  viewing (open the file in a browser); the JSON is what the host UI
  consumes inline.
- `test_render.py`: new test asserts the JSON is a valid Plotly figure
  spec containing the expected shapes (carrier + harmonic vlines) and
  annotations (HD2/HD3 labels, SFDR metrics card).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant