Skip to content

Refactor: tidy library organization (session/, vhi/, slim core.py, persistence dedup)#8

Merged
RaulSimpetru merged 69 commits into
mainfrom
refactor/library-organization
Jun 7, 2026
Merged

Refactor: tidy library organization (session/, vhi/, slim core.py, persistence dedup)#8
RaulSimpetru merged 69 commits into
mainfrom
refactor/library-organization

Conversation

@RaulSimpetru

Copy link
Copy Markdown
Member

Reorganizes the package for clearer boundaries. Behavior-preserving (moves, re-exports, docs only) — verified by the full suite staying green (163 passed) after every task. Backwards compatibility intentionally not preserved (no shims); all in-repo callers updated.

Changes (one commit per item)

  1. session/ subpackage_session_core/_session_io/_session_windows + the facade grouped into myogestic/session/ (_core.py/_io.py/_windows.py + __init__.py). Public myogestic.session.* unchanged.
  2. Slim core.py (610→528) — asset/icon/macOS-dock helpers extracted to myogestic/_platform.py; App/Context/AppState stay put.
  3. Persistence dedup — single serialization implementation (joblib in myogestic.models); myogestic.ml.save_pickle/load_pickle now thin wrappers (keep the parent-dir-creation convenience) over it. Cross-reference docstrings added so ml (lifecycle) vs models (recipes) is clear.
  4. tools/ docsmyogestic/tools/README.md documents the two audiences (end-user install_vhi vs dev/test signal generators). No file moves.
  5. vhi/ subpackage (breaking)interfaces.py, _vhi_client.py (→_client.py), and _proto/ grouped into myogestic/vhi/. myogestic.interfaces is removed; all callers (6 examples + test_interfaces + install_vhi + widgets) now use myogestic.vhi / myogestic.vhi.interfaces. gen_proto.py output path updated. Fixed _default_install_root's parent-chain for the deeper location (the modernized test caught it).

Result

  • Root modules: 14 → 10 (no more _session_*, _vhi_client, interfaces at top level).
  • New packages: myogestic/session/, myogestic/vhi/.
  • git diff --stat: 32 files changed, +500/-128.

Notes

Plan: docs/superpowers/plans/2026-06-04-library-reorganization.md.

…-> ml

Per Codex review: one namespace for swappable first-party recipes.
- myogestic/recipes/{features.py, estimators.py} replaces myogestic.contrib
  (features) and the vestigial single-file myogestic.models (estimators).
- Estimator recipes lose save_model/load_model; the single joblib persistence
  implementation now lives in myogestic.ml (save_pickle/load_pickle).
- Update all callers (examples + tests + docs); rename test_models -> test_recipes.
- recipes is named for purpose (swap-me starters), not provenance like contrib.
Match the sources/outputs/recipes convention: __init__ is a docstring +
re-export shell, implementation lives in a named module. Import paths
(from myogestic.bridges import Bridge/WebCamBridge/CustomBridge) unchanged.
- core.py: drop now-unused Path/_assets_folder imports and sort the block
  (fallout from extracting helpers into _platform.py).
- pyproject: remove the stale per-file-ignore for the moved session.py;
  exclude the generated vhi/_proto stubs from ruff.
- edge_trigger: PEP 695 generic syntax (class EdgeTrigger[T]).
- grid: Union[Px, Fr] -> Px | Fr.
- stream: move numpy/RingBuffer imports above the browser-detect code (E402).
- widgets/__init__, the 6 synthetic examples, test_interfaces: import-sort /
  unused-import auto-fixes (fallout from the reorg import rewrites).
- pyproject: exclude the Pyodide playground demo from ruff (load-bearing
  mid-file import order; it's a docs asset, not library source).

ruff check . is now clean; 163 tests pass; properdocs build clean.
os.popen is soft-deprecated. Replace the macOS dark-mode check with
subprocess.run (capture_output, check=False); absent AppleInterfaceStyle key
(Light mode) -> empty stdout -> not dark. OSError -> not dark.
The acquire loop appended to self._data/_timestamps (RingBuffer | None) relying
on the _connected flag, which the type checker can't tie to non-None. Add an
explicit None guard matching get_window/get_display, silencing the warning and
adding harmless defence.
Bind each M4 scratch buffer to a local *before* its lazy-alloc guard so the
type checker narrows the local (it won't narrow an Optional instance attribute
across the loop + downsampler calls); also saves repeated attribute lookups in
the per-frame hot path. Mark the hasattr-guarded optional Source.reconnect()
call with type: ignore[attr-defined].
…ore)

_m4_downsampler was typed 'object | None' with a # type: ignore on .downsample,
which strict type-checkers flag as 'object is not callable'. Type it as the real
tsdownsample.M4Downsampler via a TYPE_CHECKING import and bind it to a narrowed
local; no suppression needed.
… numpy

Convert all Google-style docstrings in the myogestic/ package to NumPy
(numpydoc) format (Parameters/Returns/Raises/Attributes/Notes/Examples with
underlines; param types come from annotations via Griffe) and flip the
mkdocstrings handler to docstring_style: numpy. Docs build clean (0 warnings).
…ubpackages

Group the 26 flat widget modules by responsibility:
- signals/  viewer, raw, stream_panel, scan, plot, controls, state, transforms
- plots/    scatter, heatmap, line_plot
- panels/   process_launcher, recording, log_panel, popout, app_logo,
            filter_controls, log_box
- training/ feature_selector, session_manager, session_state, template_inspector,
            trial_preview, prediction_label
- vhi/      palette, panel
De-underscore the public-in-practice helpers (_common -> common, _log_box ->
panels/log_box, etc.) and extract apply_display_filter into signals/transforms.
Public API unchanged (facade __init__ re-exports the same 26 names); all
in-repo callers + docs autodoc paths updated. No back-compat stubs.
The two-audiences split is already obvious from the module names + their own
docstrings; a package-dir README that isn't rendered in the docs site earns
nothing.
…_init__

ml/__init__.py held the entire ~300-line Pipeline + PipelineState. Extract them
into ml/pipeline.py and reduce __init__ to a facade (docstring + re-export
Pipeline, PipelineState, save_pickle, load_pickle). Matches the sources/outputs/
recipes/bridges convention. Public API (from myogestic.ml import Pipeline, ...)
unchanged.
…_init__

outputs/__init__.py held the entire ~140-line Output base class. Extract it to
outputs/base.py and reduce __init__ to a facade (re-export Output, LSLOutlet,
UDPOutput). Concrete outputs now import Output from outputs.base (not the
package) to avoid an init-order cycle. Public API unchanged.
Match the codebase convention (session/_core, otb/_base) for modules with no
importers outside their own subpackage:
- signals/{plot,controls,state,scan} -> _plot/_controls/_state/_scan
- training/session_state -> _session_state
Public entry points (viewer/raw/stream_panel, session_manager) and
cross-subpackage-shared helpers (signals/transforms, panels/log_box) keep their
bare names. Internal cross-imports updated.
Two surgical root tidies (Codex-reviewed):
- filters.py -> outputs/filters.py: it is output-side post-processing of
  prediction/control vectors, not a core primitive. outputs/ already owns
  'vectors leaving the app'. Re-exported from myogestic.outputs (OneEuroFilter,
  GaussianFilter, IdentityFilter, VectorFilter, make_filter). Callers + docs
  autodoc/xrefs repointed to myogestic.outputs.filters.
- theme.py -> _theme.py: only core.py imports it, not re-exported, runtime
  styling plumbing -> private per convention.

Root now holds only the public spine (core, stream, contracts, grid,
edge_trigger) plus private runtime support (_browser, _platform, _theme).
The Astral 'ty' type checker doesn't recognize mypy/pyright-specific ignore
codes ('# type: ignore[arg-type]', '# pyright: ignore[...]', '[union-attr]'),
so it kept reporting errors that were already suppressed for the other checkers.

A bare '# type: ignore' is honored by mypy, pyright, AND ty, so collapse the
checker-specific comments to it on the affected lines:
- StreamOutlet.push_sample(np.ndarray) — over-strict mne_lsl stub wants its
  private ScalarArray subclass (6 examples + outputs/lsl.py)
- portable_file_dialogs result polling — pfd has no stubs, object is untyped
  (2 examples + widgets/training/session_manager.py)

Verified ty + pyright + ruff + pytest all clean.
Streams were pinned to float32 (hardcoded in LSLSource), even though the buffers
and Zarr recording already honor StreamInfo.dtype. Open it up:

- StreamInfo: add SUPPORTED_DTYPES (float16/32/64, int8/16/32/64 — every LSL
  wire format plus float16 as a storage cast; no unsigned, none on the LSL
  wire). __post_init__ normalises str/type/np.dtype and validates. Default
  stays float32.
- LSLSource(stream_name, dtype='float32'): cast incoming samples to the chosen
  dtype; dtype=None honors the outlet's native wire format (lossless for int
  amps). Fixes the old connect() docstring that claimed dtype came from
  metadata while hardcoding float32.
- The window handed to @pipeline.extract is ALWAYS upcast to float32 — at the
  live boundary (Stream.get_window) and the training boundary (iter_labeled_
  windows / iter_aligned_windows) — so feature/ML code never branches on dtype,
  while buffers + recording stay compact (e.g. int16 = half memory/disk).

Net win: 16-bit EMG amps can stream/record losslessly at half the size.
Recording already used info.dtype, so no change needed there.

Also: bare '# type: ignore' on the reconnect() call so ty honors it.
…nerator

mne_lsl's pull_chunk is typed to return list[list[str]] but yields a numpy
array for numeric streams. float(data[-1, 0]) is valid at runtime but trips
both pyright and ty (can't 2D-index a list). Wrap in np.asarray() — a no-op on
the real ndarray, but it gives the checker a genuine array. Real fix, no
suppression.
Sample conversion of the four tools CLIs. Typer is type-hint driven: main()'s
typed params become the CLI, so 'args.X: Any' (an opaque hole for ty/pyright) is
gone, --help is auto-generated, and there's far less boilerplate. Adds typer as
a runtime dep (myogestic-install-vhi is a console script).

- argparse block -> typed main(...) signature with Annotated[..., typer.Option]
- python -m entry switched to typer.run(main); '# -m' invocation unchanged
- renamed the loop's local sample-array 'chunk' -> 'samples' so it no longer
  collides with the new --chunk parameter (the collision overwrote the param
  and crashed standard_normal() on the 2nd loop iteration)

Pure helpers (_class_pattern/_read_mode) and control_outlet() are untouched, so
examples + tests that import them are unaffected.
…am) to Typer

Finishes the argparse -> Typer migration across all four tool CLIs. Type-hint
driven: each main()'s typed params become the CLI, killing the 'args.X: Any'
hole and auto-generating --help.

- lsl_dummy: typed main() + typer.run; loop's data var 'chunk' -> 'samples' so
  it no longer shadows the --chunk param (same collision class as emg_generator)
- webcam: required --zarr / --lsl-name become required typer Options; the --zarr
  param would shadow , so the module is aliased to
- install_vhi (console script): logic moved to _install(); main() is now a thin
  typer.run(_install) wrapper so the existing 'install_vhi:main' entry point and
   both keep working.  -> .
- webcam: bare '# type: ignore' on three pre-existing stub/optional-dep issues
  (cv2 not installed, zarr.open()'s Array|Group union, push_sample stub)

Verified: ruff + ty + pyright clean on all four; --help renders; console-script
entry resolves; lsl_dummy/emg_generator run; 176 tests.
The signal-synthesis block used bare 0.02 / 0.15 literals (repeated 6x), and
0.15 was doing two conceptually distinct jobs that happened to share a value.
Extract named module constants so intent is explicit and they can diverge:

- REST_NOISE (0.02)   - idle noise floor (rest / no active DoF)
- ACTIVE_NOISE (0.15) - background noise while a gesture is active
- ENVELOPE_GAIN (0.15)- per-DoF activation-envelope amplitude

Behaviour identical (same values). The _class_pattern shape numbers (Gaussian
centre/width/2-sigma) and the resolve_streams timeouts are left inline - those
are either standard math or self-describing.
…lasses

myogestic_vhi_pb2 builds its message classes dynamically at import time, so
pyright/ty/Pylance couldn't see members like SetMovementRequest, flagging every
pb2.* usage in vhi/_client.py.

- generate myogestic_vhi_pb2.pyi (protoc --pyi_out) and commit it; checkers now
  resolve all the message types with real signatures
- gen_proto.py: add --pyi_out so future regens keep the stub in sync
- _client.py set_control_mode: the new stub surfaced a real mismatch —
  ControlMode.Value() returns int but the enum field is typed ControlMode | str.
  Pass the validated enum *name* string instead (identical wire bytes), keeping
  .Value() purely for input validation.

Verified: pyright + ty clean on _client.py, ruff clean, 176 tests.
EdgeTrigger gates a side effect to fire only when a prediction changes. Per a
Codex review: it is not an ml primitive (it knows nothing about models, features,
or pipeline state) — it shapes a *downstream effect*, so it belongs with the
send-side. outputs/ already owns "things leaving the app": outputs.filters
handles continuous vector outputs, EdgeTrigger handles discrete event outputs.

- git mv edge_trigger.py -> outputs/edge_trigger.py
- re-exported from myogestic.outputs and kept in the top-level facade, so both
  `from myogestic import EdgeTrigger` and `from myogestic.outputs import
  EdgeTrigger` work
- updated tests + docs to the facade import path

Package root now holds only the core spine (core, stream, contracts, grid) plus
private runtime support (_browser/_platform/_theme). Verified: ruff + ty +
pyright clean, 176 tests, docs 0 warnings.
…>*_cutoff_hz)

Naming-standardization sweep (1/4). Per the agreed convention (rates use hz /
*_hz; no ambiguous bare names):
- OneEuroFilter(freq=) -> hz=  (it is the call/sample rate, like every other
  rate param in the lib)
- min_cutoff -> min_cutoff_hz, d_cutoff -> derivative_cutoff_hz (units explicit;
  derivative_cutoff spelled out, no vowel-dropping)
- beta stays (unitless gain)

Updated make_filter's one_euro defaults, FilterControl's tuning dict + slider
readers, tests, and docs (post-process-output, api-cheatsheet). ruff + ty +
pyright clean, 181 tests, docs 0 warnings.
Naming-standardization sweep (2/4). iter_labeled_windows and iter_aligned_windows
took win_seconds / hop_seconds (abbreviated, seconds) — out of step with the new
ms policy for acquisition/feature durations. Rename to window_ms / hop_ms and
convert internally (samples = ms/1000 * fs).

- session/_windows.py: both iterators.
- examples: unified on WINDOW_MS / HOP_MS constants (dropped the per-example
  WIN_SECONDS seconds knob + the * 1000 at call sites), matching the RaulNet one.
- tests/test_session_pack.py: kwargs, positional args, and match= strings; the
  window-count assertions confirm 0.2 s -> 200 ms yields identical sample counts.
- docs: every iter_* example/signature/prose (also collapsed the win_s/win_seconds
  variants that had crept into the docs into the single window_ms spelling).

ruff + ty + pyright clean, 181 tests, docs 0 warnings.
Naming-standardization sweep (3/4). The viewer's display-zoom window stays in
seconds (multi-second zoom reads better as seconds), but the suffix is
normalized from the verbose _seconds to _s per the convention (no _seconds in
public API). Renamed in widgets/signals/viewer.py + _state.py; no callers passed
it explicitly, so nothing else changed.

(_state.py's pre-existing pyright/ty diagnostics in the data path are unrelated
and untouched.) ruff clean, viz + full suite (181) pass.
Naming-standardization sweep (4/4). InterfaceSpec's output_channels /
control_channels / control_pose_channels are counts, so prefix them with n_ to
match n_channels / n_classes elsewhere: n_output_channels, n_control_channels,
n_control_pose_channels. Updated the dataclass, outlet()/control_outlet()
readers, virtual_hand() constructor, and test_interfaces.py. No doc references.

ruff + ty + pyright clean, 181 tests, docs 0 warnings.
Naming sweep tier 1 (Codex-reviewed inventory):
- Session.init_stream(name) / append(name) -> stream_name (matches the getters
  get_continuous/get_trials/stream_info already using stream_name).
- Session.get_trials(pre, post) -> pre_s / post_s (they are seconds — unit was
  hidden).
- iter_aligned_windows: primary_stream -> primary_stream_name,
  aligned_streams -> aligned_stream_names, align_window_samples ->
  n_alignment_samples (names not Stream objects; count gets n_).

Callers were mostly positional; updated the kwarg/match/signature/prose sites in
examples, tests, and docs. (_core.py:211 zarr.storage pyright error is
pre-existing, unrelated.) ruff clean, 181 tests, docs 0 warnings.
Naming sweep tiers 2-4 (Codex-reviewed inventory). Breaking, pre-release.

Tier 2 (widgets):
- uid -> widget_id (template_inspector, trial_preview, session_manager,
  process_launcher + internal helpers)
- heading label -> title (prediction_label, session_manager, vhi_movement_palette,
  VhiMovementPanel)
- id-role label -> widget_id (process_launcher, FilterControl.ui)
- prediction_label: key -> class_key, proba_key -> probability_key
- trial_preview: window -> as_window (it's a bool "wrap in an ImGui window")

Tier 3:
- GaussianFilter(window) -> n_vectors (count of history vectors)
- EdgeTrigger(stable_ticks) -> n_stable_ticks
- constant_classifier(class_idx) -> class_index

Tier 4:
- InterfaceSpec.{output,control,control_pose}_stream -> *_stream_name (+ the
  examples reading vhi.control_stream -> vhi.control_stream_name)
- virtual_hand(mode) -> launch_mode
- VhiMovementPanel(refresh_min_interval_s) -> min_interval_s
- emg_generator/lsl_dummy main(): channels/classes/chunk/control ->
  n_channels/n_classes/chunk_size/control_stream_name, with explicit Typer
  flags so the CLI (--channels/--classes/--chunk/--control) is unchanged

Also: prediction_label proba index ignore -> bare '# type: ignore' (ty).
Callers (examples/tests/docs) updated; CLI flags preserved. ruff + ty + pyright
clean, 181 tests, docs 0 warnings.
API_PARAMETERS.md + tools/gen_param_inventory.py were a one-off cross-reference
aid for the naming-standardization review; they got swept into the tier-1 commit
by git add -A. They're not part of the library — remove them.
The VectorFilter.__call__ timestamp arg was a bare one-letter `t` with no
docstring anywhere — no way to know it's the per-sample LSL-clock time that
OneEuroFilter uses for the real dt. Rename `t` -> `timestamp` (matches the
codebase's timestamp/timestamps), and document the __call__ contract (x,
timestamp, returns) on the VectorFilter protocol. No caller passed it.
ruff + ty + pyright clean, filter tests pass.
The OneEuroFilter t->timestamp rename missed two t= kwargs in
test_one_euro_with_explicit_timestamps. Fixed; 16 filter tests pass.
Make docstring coverage/format a checked standard (decided with Codex), so the
next cryptic-`t`-style gap is caught in CI rather than by eye.

- ruff: add `D` to select, `[tool.ruff.lint.pydocstyle] convention = "numpy"`,
  ignore D105 (magic methods), D107 (ctor docs live on the class — mkdocstrings
  merge_init_into_class), D401 (descriptive API summaries). tests/ + examples/
  are D-exempt.
- Filled every resulting gap (~61): undocumented public methods/functions,
  module + package docstrings, and D205 summary-blank-line formatting. Moved
  Pipeline's __init__ Parameters onto the class docstring (house style:
  constructor docs on the class; dataclasses use Attributes).
- Documented the filter __call__ contract, the source read/connect/disconnect
  methods, the ml widgets, and the pipeline transition methods.

ruff + ty + pyright clean, 181 tests, docs 0 warnings.
Last cryptic-arg cleanup: 'on' was a bare 2-letter bool, not an established
idiom. Renamed the param to 'active' (the method is set_active). No external
callers. A full <=3-char public-param audit found everything else is an
accepted idiom (fn/fs/hz/ctx/app/emg/key/x), so this completes it.
The t->timestamp filter-param rename updated filters.py + tests but missed 7
hand-written doc examples still calling `pose_filter(pose, t=...)`,
`add_label(class_idx, t=local_clock())`, and the api-cheatsheet `filter(x, t=None)`
signature (add_label and the filters both take `timestamp` now). Fixed.

A full re-scan of docs for every renamed param (window_ms, *_hz cutoffs,
n_stable_ticks, n_vectors, stream_name, *_stream_name, n_alignment_samples,
class_key/probability_key, launch_mode, min_interval_s, etc.) found no other
stale references — the autodoc pages render live signatures, and the prose was
updated during each sweep. docs build 0 warnings.
- FilterControl.__call__: t -> timestamp (the filter-rename missed this wrapper;
  FilterControl()(x, timestamp=...) used to TypeError). Added a regression test.
- type-ignore standard refined and applied: bare `# type: ignore` only where ty
  itself errors (the `import serial` unresolved-import + a couple attr accesses);
  keep the specific code for pyright/mypy-only suppressions (implot.Cond_,
  Popen type-arg) — bare there triggers ty's unused-blanket warning. Net: fewer
  ty diagnostics than before.
- packaging: package-data proto path _proto/*.proto -> vhi/_proto/*.proto (moved).
- add_label(): added docstring (public, rendered in docs); internal t -> ts.
- internal-name consistency: VhiMovementPanel._refresh_interval -> _min_interval_s,
  _ConstantClassifier.class_idx -> class_index, OneEuroFilter._t_prev ->
  _timestamp_prev, add_recorded_session(label) -> title (matches session_manager).
- signal_viewer docstring buffer_seconds -> buffer_ms.
- sklearn _require hint: --extra dev -> examples (consistent with catboost).

ruff clean; 182 tests; docs 0 warnings. (Remaining ty data-path diagnostics in
_state.py/_plot.py/stream_panel are pre-existing, pyright/ty not CI-gated.)
- contrib -> recipes: README, docs/api/index, properdocs nav; renamed
  use-contrib-features.md -> use-recipe-features.md (the module became
  myogestic.recipes.features long ago).
- Stale filter `t=` examples/prose fixed (enable-recording, post-process-output,
  widget-gallery, troubleshooting) after t->timestamp.
- Moved-file references updated to the post-reorg signals/ layout (concepts/widgets,
  add-a-widget, design-principles); process_launcher docstring import fixed.
- api-cheatsheet: corrected stale signatures (template_inspector/trial_preview
  widget_id, plot data/channel_names) and softened the "every public symbol" claim.
- CHANGELOG [Unreleased]: full breaking-change migration note (module moves +
  old->new parameter map) for the reorg + naming standardization.

docs build 0 warnings.
These are doc code blocks that compile as prose but would fail at runtime —
structural API misuse the param-name greps couldn't catch:
- record-and-replay.md / add-a-model.md: iter_aligned_windows used old
  primary=/aligned= kwargs, 2-value unpack, and sw.data. It takes
  primary_stream_name/aligned_stream_names and yields (window, aligned, ts).
- record-and-replay.md: get_trials(pre=/post=) -> pre_s/post_s; r.ts ->
  r.timestamps; corrected the Recording.data layout claim (it is sample-major
  (n_samples, n_channels), not channels-first).
- anatomy.md: iter_labeled_windows yields the window ndarray directly, not an
  object with .data (sw.data -> window).
- api-cheatsheet: SerialSource/SerialOutput are direct-import only (not on the
  facades) — noted the import path.
Nice-to-haves: dropped "every public symbol" claim in README + reference/index;
persistence is under ml not recipes (api/index, architecture); uid -> widget_id
wording (troubleshooting, template_inspector docstring); WIN_SECONDS/HOP_SECONDS
-> WINDOW_MS/HOP_MS (record-good-training-data, troubleshooting).

Codex confirmed examples/synthetic/*.py call the current API correctly and the
CHANGELOG migration map matches real signatures. docs build 0 warnings.
Doc code blocks rotted silently (the FilterControl/iter_*_windows examples)
because nothing ran them and CI was docs-only. Close that gap:

- tests/test_docs.py: (1) parse-checks every ```python block in docs/ + README
  (catches syntax/indentation); (2) executes blocks tagged `<!--docs:run-->`
  against a synthetic session in a per-file namespace — a stand-in pipeline whose
  @train decorator invokes immediately, so a tagged @pipeline.train block really
  drives iter_*_windows. This catches wrong-kwarg / wrong-attribute / wrong-unpack
  bugs a parse check can't. `<!--docs:skip-->` drops a block from both layers.
- Tagged the data-API examples that previously broke (record-and-replay's
  iter_labeled/iter_aligned, add-a-model's iter_aligned). Verified the run-layer
  catches a reintroduced `window.data` regression.
- Fixed anatomy.md's illustrative `recording_controls, ...` import to a real,
  parseable import (no behavior/intent change).
- .github/workflows/tests.yml: run ruff (incl. pydocstyle D) + the full pytest
  suite on push/PR. Lean extras (dev/grpc/serial); sklearn/catboost recipe
  assertions importorskip without pulling torch.

Local: `uv run pytest tests/test_docs.py` (`-k run` for just the executed blocks).
314 tests pass; docs build 0 warnings.
Codex's "examples as source of truth" review (option B of B+D+C): run each
examples/synthetic/*.py via runpy with App.run monkeypatched to a no-op, so all
module-level + __main__ wiring (App/Stream/Pipeline construction, decorator
registration, widget hookup, virtual_hand/VhiControlClient config) executes
against the current public API without opening a window. Catches renamed kwargs /
moved imports / wrong widget-factory signatures in the real example files —
stronger than the AST-only pass.

Examples that top-import an absent optional dep (torch/myoverse: the RaulNet /
MyoVerse examples) skip cleanly, so this runs lean in CI (the 2 dep-free examples)
and fully wherever --extra examples is installed. All 6 pass locally.
The example smoke test failed in CI: examples call vhi.launcher() at module
level, which raises FileNotFoundError unless the VHI binary is installed (an
environment dependency, not API surface). Stub InterfaceSpec.launcher -> [] in
the test; a renamed/removed method would still AttributeError and fail. Locally
(VHI installed) all 6 pass; lean CI now runs the 2 dep-free examples and skips
the 4 torch/myoverse ones.
Codex's "examples as source of truth" (option D). The tutorial had structurally
drifted — it showed a torch/MyoVerse `rms_transform`/`mav_transform` extract, but
the real example uses a FeatureSelector over myogestic.recipes.features. Replace
the hand-copied blocks (filter/features/setup/extract/train/predict) with
pymdownx `--8<--` snippet includes from sectioned markers in
examples/synthetic/emg_classification.py, and fix the now-stale prose/intro. The
tutorial code now IS the runnable, smoke-tested example code and can't drift.

- example: added `# --8<-- [start:NAME]/[end:NAME]` markers (comments, no
  behaviour change; smoke test + ruff still pass).
- tests/test_docs.py: skip `--8<--` include blocks (not literal python; the
  source file is covered by tests/test_examples.py).
- docs build 0 warnings; include verified rendered in site/.
Apply the source-of-truth pattern to the regression tutorial's one clean
full-function block: marker in emg_regression.py + `--8<--` include. Its
iter_aligned_windows / iter_labeled_windows fragments are deliberately split for
teaching and already match the current API (positional args), so they stay inline
(parse-checked by tests/test_docs.py).
…ocks

- feature-extraction-cookbook: section 1 claimed to be "the emg_classification.py
  baseline" but showed the OLD torch/MyoVerse extract — same drift the tutorial
  had. Reframed: the shipped baseline now includes the real FeatureSelector +
  extract from the example; the MyoVerse version stays as a clearly-labeled
  "bring your own" alternative.
- classification tutorial: vhi/HAND_REST/HAND_FIST poses block -> :poses include.
- regression tutorial: VHI_DOF_INDICES -> :dofs, grid layout -> :grid includes.

That covers every doc block that's a verbatim copy of a contiguous example
section. Left inline (by design): aggregated import+usage snippets, the regression
train's two deliberately-split teaching fragments (the real train has extra
session-splitting/logging that would bloat the lesson), and the generic
illustrative fragments on how-to/concept pages (no single source example) — all
still parse-checked by tests/test_docs.py. docs build 0 warnings; 310 tests pass.
…ex check)

The snippet includes surfaced stale prose the conversion didn't touch:
- classification tutorial: removed the non-existent "Launch VHI" button copy
  (the example's PROCESSES only launches the EMG generator; VHI runs separately),
  corrected the left-column panel list, and replaced the stale Grid(6,3) /
  VHI_PROCESS layout block with a `:layout` include of the real Grid(8,3) +
  demo_ui. Dropped the brittle exact line count.
- regression tutorial: fixed "both iterators ignore class chips" — only the
  labeled fallback filters by class; the kinematics path regresses every window.
- cookbook + tests/test_docs.py comment: softened now-inaccurate wording.

All 13 includes resolve (check_paths would fail the build otherwise); docs build
0 warnings, ruff clean, example smoke + full suite (309) pass.
…lot check)

Copilot's read-only review found no must-fix issues but flagged two remaining
hand-copied fragments as drift candidates — and one had already drifted (the
labeled-loop dropped the example's dtype= args). Close them:

- Added nested section markers in emg_regression.py: :kin_loop, :label_loop
  (inside train), :expand (inside predict).
- Enabled `dedent_subsections: true` in the pymdownx.snippets config so these
  inside-function fragments render at column 0 instead of the function's indent.
- Converted the three regression-tutorial fragments to those includes. The
  nested :expand markers are stripped from the surrounding :predict include
  (verified 0 stray --8<-- in the rendered page).

Now every verbatim code copy in the two tutorials + the cookbook is a snippet
include from the smoke-tested examples. Both Codex and Copilot reviews come back
clean. docs build 0 warnings; ruff clean; example smoke + full suite (306) pass.
…ange)

ty went from 20 errors -> 0; pyright from 29 -> 0 (6 benign stub warnings).
Real fixes (narrowing / annotations / casts / imports), no runtime change:
- popout._make_dockable_window -> typed DockableWindow (fixes core.py dockable
  list assignment at the source).
- session/_core.py + _io.py: `import zarr.storage` so zarr.storage.ZipStore
  resolves; declared Session._zip_store (a write-only ZipStore lifetime anchor)
  in __init__ with a comment.
- _state.py: ViewerState.frozen_ts/frozen_data typed np.ndarray|None (were
  object|None); paused-render guard also checks frozen_ts (set together with
  frozen_data, so behavior-equivalent); assert info before .fs.
- _plot.py: assert stream.info before .fs (caller already returns on None).
- raw.py: cast the heterogeneous buffer dict's known keys.
- stream_panel._connect_buttons: param typed Stream (only called post-isinstance).
- _session_state.py: cast label dict for .get; log_box: bool(still_open).
Genuine third-party-stub gaps -> bare `# type: ignore`: AppKit/pyobjc members
(_platform.py), and the optional pyserial / dvg-ringbuffer lines (config rule
unused-type-ignore-comment="ignore" since "needed" depends on installed extras).
Generated proto excluded from ty + pyright (mirrors the ruff exclude).

ty + pyright + ruff clean, 306 tests, docs 0 warnings, wheel still imports.
Reported: saving (pack to .session.zip) fails on Windows. The finalize path
assumed POSIX semantics — you can delete/rename files with open handles, and GC
releases zarr chunk handles in time. Windows refuses both (WinError 32), so the
rmtree/rename after writing the zip throws and the save appears to fail.

- pack_to_zip: gc.collect() after dropping zarr array refs to release chunk-file
  handles; shutil.rmtree -> _robust_rmtree (retry + clear read-only bit, the
  standard Windows pattern); Path.rename -> os.replace (atomic overwrite; Windows
  rename raises if the destination exists).
- Session.close() + context-manager support to close the ZipStore opened by
  open_session_store (an open ZipStore locks the .session.zip on Windows);
  open_session_store now always sets _zip_store (None for folders) so close() is
  safe despite the __new__ bypass.
- Tests: os.replace overwrite, close() idempotency + context manager, folder
  session close-safety.

CI: add a windows-latest job to the Tests matrix (fail-fast: false) so this path
is actually exercised on Windows. Lint runs once on Linux.

ruff + ty clean, 309 tests on macOS/Linux.
The new windows-latest CI job caught two real things:
- iter_labeled_windows / iter_aligned_windows opened a session per path via
  open_session_store but never closed it — leaking the ZipStore handle, which on
  Windows locks the .session.zip (can't delete/move it after training). Wrapped
  each per-path body in try/finally: sess.close().
- test_pack_to_zip_roundtrip leaked a ZipStore handle (open_session_store without
  close) → tempdir cleanup hit WinError 32; now context-managed.
- test_pack_to_zip_failure_keeps_folder used chmod(0o555) failure injection,
  which is POSIX-only (Windows ignores the read-only bit on dirs) → skipif win32.

ruff + ty clean, 309 tests on macOS/Linux.
…leaks

Leak audit (all open_session_store callers):
- ReplaySource opened a session in connect() but disconnect() only reset _pos,
  never closing it — a replayed .session.zip stayed locked until GC (Windows
  can't delete/re-record it). Now holds the session and closes it on disconnect
  (and on the stream-missing error path). Added a .session.zip disconnect test.
- examples/synthetic/emg_regression.py + emg_regression_raulnet.py opened a
  session just to check `"vhi_control" in sess.stores` then dropped it, leaking a
  ZipStore per session during training — now close right after the check.
- webcam.py's zarr store runs in the bridge *subprocess*, reclaimed by the OS on
  exit — not a main-process lock, left as-is.

(iter_*_windows + pack_to_zip + open_session_store were fixed in the prior
commits.) ruff + ty clean, 310 tests on macOS/Linux.
@RaulSimpetru RaulSimpetru merged commit b91e043 into main Jun 7, 2026
4 checks passed
@RaulSimpetru RaulSimpetru deleted the refactor/library-organization branch June 7, 2026 09:32
RaulSimpetru added a commit that referenced this pull request Jun 7, 2026
Merged main (PR #8: library reorg, naming standardization, ruff D / ty-clean,
Windows session fix, doc/example test harness) into the OTB device-sources branch
and brought the PR up to date:

- API: window_seconds= -> window_ms= in the OTB example + connect-otb-devices.md
  (StreamInfo/Source usage was unchanged, so the sources needed no edits).
- ruff (new D + rules now apply to OTB code): package docstring for sources/otb,
  D400 period fix in _constants, disconnect() docstring in muovi; gave tests the
  same E402 import-placement latitude examples already have; dropped an unused
  import.
- ty (repo is ty-clean now): narrowed the socket/StreamInfo Optionals in
  _base/muovi/quattrocento (local-bind in read(); asserts before use) — no
  behaviour change.
- test_docs harness: handle code blocks nested in list items (indented closing
  fence + dedent) so connect-otb-devices.md parses.

ruff + ty clean, 337 tests, docs 0 warnings. Version stays 2.0.2.
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