diff --git a/.claude/skills/generate-database-options/SKILL.md b/.claude/skills/generate-database-options/SKILL.md index 514ff1a..46ddfae 100644 --- a/.claude/skills/generate-database-options/SKILL.md +++ b/.claude/skills/generate-database-options/SKILL.md @@ -114,9 +114,9 @@ Path(json_path).write_text( ## Reference -Every column, its scope and its default: `docs/user_guide/tutorial.md` → *database_options.xlsx*. `example/demo_database/database_options.xlsx` shows all four sheets populated — read it for structure, not as a model of good clinical config. +Every column, its scope and its default: `docs/user_guide/user_guide.md` → *database_options.xlsx*. `example/demo_database/database_options.xlsx` shows all four sheets populated — read it for structure, not as a model of good clinical config. -Not in the tutorial: **a signal reference resolves three ways** (`signal_reference.resolve_signal_references`) — qualified `datasource::raw_name`, then display name, then raw name. Qualify whenever one raw name lives in two datasources. +Not in the user guide: **a signal reference resolves three ways** (`signal_reference.resolve_signal_references`) — qualified `datasource::raw_name`, then display name, then raw name. Qualify whenever one raw name lives in two datasources. ## Before finishing diff --git a/.claude/skills/new-datasource/SKILL.md b/.claude/skills/new-datasource/SKILL.md index 1c2b91b..adaa44e 100644 --- a/.claude/skills/new-datasource/SKILL.md +++ b/.claude/skills/new-datasource/SKILL.md @@ -95,7 +95,7 @@ Beyond the three new files in `src/clinical_scope/datasource/sources//`, e - **`src/clinical_scope/datasource/registry.py`** — import the new module, add an inner class to `DataSource`, append it to `AVAILABLE` **before `Other`** (`Other` must stay last). `NAME` must equal `DATASOURCE_NAME` — the decorator raises at import time if not. - **`tests/datasource/conftest.py`** — add a session-scoped `_cls` fixture. -- **`docs/user_guide/tutorial.md`** → *Patient Data & Supported Data Sources* canonical table — add a row. +- **`docs/user_guide/user_guide.md`** → *Patient Data & Supported Data Sources* canonical table — add a row. - **`CLAUDE.md`** → *Supported Data Sources* bullet list — add a bullet, list order aligned with `AVAILABLE`. - **`example/template_patient_data_structure//.gitkeep`** — the empty scaffold that ships in the release bundle. - **`example/demo_database/database_options.xlsx`** — add a section for the new source (a `*` sentinel row plus a curated handful of signals), then **regenerate `database_options.json` from it**; the demo must plot every source it ships. @@ -165,7 +165,7 @@ Once everything is in place, mention the primary for transparency: - [ ] `tests/datasource/conftest.py` — fixture added - [ ] `tests/datasource/test_.py` — copied from primary and adapted - [ ] `tests/expected_results//` — snapshots generated -- [ ] `docs/user_guide/tutorial.md` — table row added +- [ ] `docs/user_guide/user_guide.md` — table row added - [ ] `CLAUDE.md` — Supported Data Sources bullet updated - [ ] `example/demo_database/database_options.{xlsx,json}` — section added to the xlsx, json regenerated from it - [ ] `README.md` — updated only if it enumerates sources diff --git a/.claude/skills/new-plot-type/SKILL.md b/.claude/skills/new-plot-type/SKILL.md index 9ef8852..4555b6d 100644 --- a/.claude/skills/new-plot-type/SKILL.md +++ b/.claude/skills/new-plot-type/SKILL.md @@ -149,7 +149,7 @@ type that exists only in code: - `example/demo_database/database_options.xlsx` — configure one plot of the new type over demo signals, then **regenerate the json from it**; `tests/unit/test_example_assets.py` prints the one-liner. Pick signals the plot is honest on, not merely present. -- `docs/user_guide/tutorial.md` — a heading naming the type, under *Configuration File +- `docs/user_guide/user_guide.md` — a heading naming the type, under *Configuration File Reference*. The `` `spectrogram` Block `` and `` `spectrograms` sheet `` sections are the shape: the keys, a JSON example, and what each field does, in clinician-facing language. - `CONTEXT.md` — a `**Name**:` entry under *Core concepts*, with the `_Avoid_` line naming @@ -169,7 +169,7 @@ The last two answer to nothing but this skill, which is what makes them the ones - [ ] `src/clinical_scope/plot_types/registry.py` — import + `AVAILABLE` - [ ] `src/clinical_scope/plot_types/registry.py` — import + `BUILDERS` - [ ] `example/demo_database/database_options.{xlsx,json}` — configured, json regenerated -- [ ] `docs/user_guide/tutorial.md` — a heading and its section +- [ ] `docs/user_guide/user_guide.md` — a heading and its section - [ ] `CONTEXT.md` — glossary entry - [ ] `CLAUDE.md` — derived-type list - [ ] `tests/plot_types/test_.py` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 817984c..7f8d1c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,3 +99,69 @@ jobs: files: ${{ matrix.artifact_name }}.zip draft: true make_latest: false + + # The user guide PDF, published as a release asset so the app's Docs link resolves to a + # released guide rather than to whatever `main` happens to hold. Committed, never built here: + # regenerating it needs pandoc and LaTeX (docs/user_guide/build_pdf.sh, run once per release). + # The asset name is pinned by constants.USER_GUIDE_URL, which every published wheel already + # carries: renaming it here breaks the Docs link for every version already installed. + user_guide: + name: ClinicalScope_UserGuide.pdf + if: github.event_name == 'push' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Attach to GitHub Release (tag push) + uses: softprops/action-gh-release@v2 + with: + files: docs/user_guide/ClinicalScope_UserGuide.pdf + draft: true + make_latest: false + + # The demo dataset, published as a release asset so `clinical-scope --demo` has something to + # fetch. The standalone bundle still carries its own copy (assemble_bundle.py); this exists + # for pip installs, which package no example at all. + # The asset name is pinned by constants.DEMO_ARCHIVE_URL, which every published wheel already + # carries: renaming it here breaks `--demo` for every version already installed. + example: + name: clinical-scope-example.zip + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # Staged through a filtered copy so the per-run parquet cache and macOS metadata stay out + # of the archive. Same list as _TREE_IGNORE in assemble_bundle.py; keep the two in step. + - name: Build the example archive + run: | + python3 - <<'EOF' + import shutil, tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmp: + staged = Path(tmp) / "example" + shutil.copytree( + "example", + staged, + ignore=shutil.ignore_patterns("clinical_scope_output", ".DS_Store", "__MACOSX"), + ) + shutil.make_archive("clinical-scope-example", "zip", root_dir=staged) + EOF + + - name: Upload artifact (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: clinical-scope-example + path: clinical-scope-example.zip + retention-days: 7 + + - name: Attach to GitHub Release (tag push) + if: github.event_name == 'push' + uses: softprops/action-gh-release@v2 + with: + files: clinical-scope-example.zip + draft: true + make_latest: false diff --git a/.gitignore b/.gitignore index b01e3b5..8bf939d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ logs/ # Local testing and example **/clinical_scope_output/ +/clinical-scope-example.zip LOCAL_SCRIPT/* *_from_xlsx.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4643b..8c5eb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- **`clinical-scope --demo` downloads the demo dataset.** Installing with `pip` gives you the application but none of the example data — the demo recording ships with the standalone application and with a source checkout, and until now a `pip` user had no way to get it. The command downloads it once, into `~/.clinical_scope/example/`, and prints the data folder and config paths the app asks for. Repeating it costs nothing: an existing demo is left alone unless you pass `--force`. + + `clinical-scope --help` now lists what the command line offers, and `clinical-scope --version` prints the installed version. Launching the app is still just `clinical-scope`. + +- **A 📖 Docs link in the app.** It sits under the ⚙ Settings button in the top-right corner and opens the user guide in a new tab, always at its newest version. Previously the guide could only be found by going to the project page, which a `pip` install never sends you to. + - **The version badge says when a newer release is out.** The badge in the top-right corner has always named the version you are running. It now also asks PyPI what the newest published version is, and when you are behind it gains a link — `API Version: 1.2.0 | 1.3.0 available ↗` — pointing at that release's notes and downloads. Until now the only way to learn that a new version existed was to be told by someone. When the check cannot reach PyPI, or cannot make sense of what it gets back, the badge shows `API Version: 1.2.0 | releases ↗` instead: it will not claim you are behind, but the page it points at is where the answer is. An install that is genuinely up to date gets no link at all, so the quiet case stays quiet. @@ -20,8 +26,12 @@ All notable changes to this project will be documented in this file. - **The hover panel style now applies to PSD plots too**, where several spectra share one frequency axis and reading them at one frequency is the point. A spectrogram's hover also follows the *Hover: x-axis time format* setting, instead of always printing the full date and time. +### Fixed +- **The project page on PyPI now links to the documentation.** Its description is the project README, whose links all pointed at files inside the repository — on PyPI those led nowhere, and the demo animation did not appear at all. Every link is now absolute, and the page carries *Documentation*, *Changelog* and *Issues* links in its sidebar. + ### Documentation - The README's PyPI section says how to upgrade an existing install, not only how to make a new one. +- The user guide has a *Trying the Demo Dataset* section, covering both the copy bundled with the standalone application and the `--demo` download. --- diff --git a/CLAUDE.md b/CLAUDE.md index e0e271d..69c276e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ pip install -e . # in your virtualenv clinical-scope # launches the Dash app at http://127.0.0.1:8050 ``` -CLI scripts (extract / inspect / visualize) and the Python API are documented in [README.md](README.md) and the [user guide](docs/user_guide/tutorial.md). Packaging to a standalone executable lives in `src/clinical_scope/build_info/` (`build.sh` + README). +CLI scripts (extract / inspect / visualize) and the Python API are documented in [README.md](README.md) and the [user guide](docs/user_guide/user_guide.md). Packaging to a standalone executable lives in `src/clinical_scope/build_info/` (`build.sh` + README). ## Where things live @@ -65,7 +65,7 @@ src/clinical_scope/ ## Datasources -Registered in `datasource/registry.py` (`DataSource.AVAILABLE`); the canonical list plus folder/file-naming rules live in the [tutorial](docs/user_guide/tutorial.md) → *Patient Data & Supported Data Sources*. A patient folder holds one subfolder per source. +Registered in `datasource/registry.py` (`DataSource.AVAILABLE`); the canonical list plus folder/file-naming rules live in the [user guide](docs/user_guide/user_guide.md) → *Patient Data & Supported Data Sources*. A patient folder holds one subfolder per source. **A module is justified only by format-specific parsing** ([ADR-0008](docs/adr/0008-datasource-modules-need-format-specific-parsing.md)). Plain CSV/parquet with a datetime column belongs in `other/`, configured per file under an `other::` key — that scope carries its own `time_shift`, timezone, grouping and trace style, so a module would add machinery and no capability. @@ -73,11 +73,11 @@ Registered in `datasource/registry.py` (`DataSource.AVAILABLE`); the canonical l **Datetime bounds are qualified at the boundary** ([ADR-0011](docs/adr/0011-datetime-bounds-are-qualified-at-the-boundary.md)). The UI turns naive form text into a tz-aware instant at Submit, using the user's `display_timezone` — *that* is what makes the Settings timezone govern the time window. The load path only ever localizes a bound that is still naive (script or hand-edited file), and does so with `cst.NAIVE_BOUND_TZ`, never a user option, so `extract_*` output does not depend on who is at the keyboard. `cst.NAIVE_BOUND_TZ` and `cst.DISPLAY_TIMEZONE` are separate literals on purpose; do not alias them. -**Adding one**: use the `/new-datasource` skill — it is authoritative for the module layout, `options.py` constants, the loader, registration (Other stays last), example data, tests, snapshots, and the tutorial table. +**Adding one**: use the `/new-datasource` skill — it is authoritative for the module layout, `options.py` constants, the loader, registration (Other stays last), example data, tests, snapshots, and the user guide table. ## Config files -Field-by-field reference is in the [tutorial](docs/user_guide/tutorial.md). The three tiers: +Field-by-field reference is in the [user guide](docs/user_guide/user_guide.md). The three tiers: - **`database_options`** (`.json` or `.xlsx`) — per-source signal config: `field_display`, `signals` (labels/units/colors), `grouped_fields`, and one section per derived plot type (`loop`, `spectrogram`, `psd`); a `global` section takes the same keys, resolved across datasources. Uploading one in the UI caches it to `~/.clinical_scope/last_database_options.json` (signal metadata only, no PHI). - **`patient_options`** (`.json`) — per-run settings: `data_folder`, `datetime_start`/`datetime_end`, `quick_load`, and per-source options (`time_shift`, `day`, …). - **`user_options`** (`~/.clinical_scope/user_options.json`) — the third tier: per-person app behaviour + display fallbacks, edited only in the Settings modal. **Never overrides `database_options`** ([ADR-0005](docs/adr/0005-user-options-are-fallbacks.md)). A new display setting = a `UserOptions` schema class (with `SECTION`) + a field on `DisplayFallbacks` (`signal_container.py`) + one read site; the carrier is threaded from `wrapper.main` down to both `Signal` and `PlotModel` construction, so no signature grows. Values are held to the schema by `user_options.validate()` at every boundary that accepts one, and only `dash_api` may read the file ([ADR-0014](docs/adr/0014-user-options-are-validated-at-the-boundary.md)). @@ -124,8 +124,8 @@ Gitignored under `logs/`: `logs/app/dash_api.log` (app), `logs/scripts/` (script - **Issue tracker** — GitHub Issues via the GitHub MCP server (`larib-data/clinical-scope`), not the `gh` CLI; see `docs/agents/issue-tracker.md`. - **Triage labels** — `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`; see `docs/agents/triage-labels.md`. - **Domain docs** — single-context repo: `CONTEXT.md` (domain glossary) + `docs/adr/` at root; see `docs/agents/domain.md`. -- **Doc audience** — `README.md` / `docs/user_guide/tutorial.md` are clinician-facing: state behavior, not implementation; never link to `docs/adr/`, `CONTEXT.md`, or CLAUDE.md from them. -- **Tutorial PDF** — the standalone bundle ships `tutorial.md` as a PDF, and nothing regenerates it: `assemble_bundle.py` copies whatever is committed. It is rebuilt once per *release*, not per commit (`./docs/user_guide/build_pdf.sh`, needs pandoc + LaTeX — [RELEASING.md](docs/RELEASING.md) step 1), so on `main` it is expected to lag `tutorial.md`. +- **Doc audience** — `README.md` / `docs/user_guide/user_guide.md` are clinician-facing: state behavior, not implementation; never link to `docs/adr/`, `CONTEXT.md`, or CLAUDE.md from them. +- **User guide PDF** — the standalone bundle ships `user_guide.md` as a PDF, and nothing regenerates it: `assemble_bundle.py` copies whatever is committed. It is rebuilt once per *release*, not per commit (`./docs/user_guide/build_pdf.sh`, needs pandoc + LaTeX — [RELEASING.md](docs/RELEASING.md) step 1), so on `main` it is expected to lag `user_guide.md`. `build.yml` also attaches it to the draft release, which is what the in-app **Docs** link resolves to (`cst.USER_GUIDE_URL` → `releases/latest/download/`). - **Project skills** (`.claude/skills/`, invoke with `/name`): | Skill | When to use | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebdc100..5a6efc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ Ruff is capped to the 0.16.x line by the dev extra — run it from the project v **First check that it needs a module at all.** The test: strip the configuration away — is any parsing left? A vendor header to decode, an XML schema, a binary layout, a channel table justifies a module. Plain CSV or parquet with a datetime column belongs in `other/`, where each file is configured under its own `other::` key carrying its own `time_shift`, timezone, grouping and trace style. Full reasoning in [ADR-0008](docs/adr/0008-datasource-modules-need-format-specific-parsing.md). Then use the `/new-datasource` skill from within Claude Code — it walks through every step -(module skeleton, options, loader, registration, example data, tests, snapshots, docs). The existing sources are catalogued in the [tutorial](docs/user_guide/tutorial.md) → *Patient Data & Supported Data Sources*. +(module skeleton, options, loader, registration, example data, tests, snapshots, docs). The existing sources are catalogued in the [user guide](docs/user_guide/user_guide.md) → *Patient Data & Supported Data Sources*. ## PR Process diff --git a/README.md b/README.md index 958730f..a756037 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python versions - + License: Apache 2.0 @@ -78,23 +78,37 @@ pip install -e . clinical-scope ``` -For the full developer setup (tests, linting, adding a datasource), see [CONTRIBUTING.md](CONTRIBUTING.md). +For the full developer setup (tests, linting, adding a datasource), see [CONTRIBUTING.md](https://github.com/larib-data/clinical-scope/blob/main/CONTRIBUTING.md). ## Demo -![ClinicalScope demo](docs/user_guide/images/demo.gif) +![ClinicalScope demo](https://raw.githubusercontent.com/larib-data/clinical-scope/main/docs/user_guide/images/demo.gif) ## Quickstart 1. **Install and run** — see [Installation](#installation) above; your browser opens at `http://127.0.0.1:8050` 2. **Load config** — click **Default visualization (all sources)** to use built-in defaults, or upload a `database_options.json` / `.xlsx` config file -3. **Set data folder** — enter the path to your patient folder (or point to the bundled `demo_database/demo_patient/` to try it immediately; for the demo, set the EIT *day* to `2004-09-15` and the EDF *recording start* to `2004-09-15 10:12:33` so every source lines up) +3. **Set data folder** — enter the path to your patient folder. No data of your own yet? See [Trying the demo](#trying-the-demo) below (for the demo, set the EIT *day* to `2004-09-15` and the EDF *recording start* to `2004-09-15 10:12:33` — neither file carries its own recording date) 4. **Process** — click **Process visualization**; interactive plots appear in the browser 5. **Annotate** — draw time events, windows, or point annotations, then click **Save** +## Trying the demo + +ClinicalScope ships a small demo recording — one patient, every supported data source — so you can see a full visualization before preparing any data of your own. + +A `pip install` does not include it, so download it once: + +```bash +clinical-scope --demo +``` + +That prints the folder it landed in, plus the `demo_patient/` path to paste into the app's **Data folder** field. A source checkout already carries the same data under `example/demo_database/`; the standalone application puts it in `demo_database/`, next to the executable. + +Run `clinical-scope --help` for the full list of commands. + ## Documentation -The **[user guide](docs/user_guide/tutorial.md)** is the primary reference for everything beyond the Quickstart: data folder layout, `database_options` config files, annotation tools, inspection view, CLI scripts, and the Python API. +The **[user guide](https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/user_guide.md)** is the primary reference for everything beyond the Quickstart: data folder layout, `database_options` config files, annotation tools, inspection view, CLI scripts, and the Python API. ## Supported Data Sources @@ -110,7 +124,7 @@ The **[user guide](docs/user_guide/tutorial.md)** is the primary reference for e | EDF / EDF+ | Amplifiers and polygraphic recorders | `.edf` | Any EDF-exported signal, typically EEG | | Other (Generic) | Any CSV / Parquet | `.parquet`, `.csv` | Any time-series with a datetime column — one independent entry per file | -Each patient folder should contain one subfolder per data source. The [user guide](docs/user_guide/tutorial.md) → *Patient Data & Supported Data Sources* gives the folder keyword for each source, the naming rules, and the configuration details. +Each patient folder should contain one subfolder per data source. The [user guide](https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/user_guide.md) → *Patient Data & Supported Data Sources* gives the folder keyword for each source, the naming rules, and the configuration details. ## Standalone Data Processing @@ -124,8 +138,8 @@ from clinical_scope import extract_datasource, extract_patient, batch_extract from clinical_scope.config.parsing import load_database_options_from_path db_options = load_database_options_from_path(Path("database_options.json")) -# No config of your own yet? The shipped demo works as-is, no UI needed: -# load_database_options_from_path(Path("example/demo_database/database_options.json")) +# No config of your own yet? The demo config works as-is, no UI needed — run +# `clinical-scope --demo`, then point at the database_options.json it reports. # 1. Single datasource subfolder (auto-detects type from folder name) df = extract_datasource( @@ -182,7 +196,7 @@ Omit `--database-options` to use all available datasources with their defaults. ## Contributing -Contributions are welcome — bug reports, new data sources, and documentation improvements. See [CONTRIBUTING.md](CONTRIBUTING.md). +Contributions are welcome — bug reports, new data sources, and documentation improvements. See [CONTRIBUTING.md](https://github.com/larib-data/clinical-scope/blob/main/CONTRIBUTING.md). ## Citation @@ -199,7 +213,7 @@ If you use ClinicalScope in academic work, please cite: } ``` -A [`CITATION.cff`](CITATION.cff) file is also provided for GitHub's *Cite this repository* button. +A [`CITATION.cff`](https://github.com/larib-data/clinical-scope/blob/main/CITATION.cff) file is also provided for GitHub's *Cite this repository* button. ## Disclaimer @@ -215,6 +229,6 @@ This software processes physiological signals that may constitute health data ## License -ClinicalScope is licensed under the [Apache License 2.0](LICENSE). +ClinicalScope is licensed under the [Apache License 2.0](https://github.com/larib-data/clinical-scope/blob/main/LICENSE). Copyright © 2026 Assistance Publique – Hôpitaux de Paris. Developed by Alexis Janin. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1e1485e..127d7e9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,7 +8,7 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap ```bash ./docs/user_guide/build_pdf.sh # needs pandoc + xelatex/pdflatex ``` - The standalone bundle ships `docs/user_guide/ClinicalScope_UserGuide.pdf`, but nothing rebuilds it from `tutorial.md` — `assemble_bundle.py` copies whatever is committed, and a months-old PDF copies without a warning. Run this whenever the tutorial changed since the last release, and **commit the regenerated PDF** so the CI build picks it up too. + The standalone bundle ships `docs/user_guide/ClinicalScope_UserGuide.pdf`, but nothing rebuilds it from `user_guide.md` — `assemble_bundle.py` copies whatever is committed, and a months-old PDF copies without a warning. Run this whenever the user guide changed since the last release, and **commit the regenerated PDF** so the CI build picks it up too. → open the PDF and confirm it describes this release (new datasources, plot types, config keys). 2. **Build locally and install from it.** @@ -18,11 +18,11 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap ``` → run `clinical-scope`, check the example. -3. **Tag and push** — this triggers [`build.yml`](../.github/workflows/build.yml), which drafts a GitHub Release with the standalone executables attached. +3. **Tag and push** — this triggers [`build.yml`](../.github/workflows/build.yml), which drafts a GitHub Release with the standalone executables and `clinical-scope-example.zip` attached. ```bash git tag vX.Y.Z && git push origin vX.Y.Z ``` - → review the draft Release: executables attached, no build warnings. + → review the draft Release: executables, the example archive and the user guide PDF attached, no build warnings. 4. **Dry-run on TestPyPI** — run **Publish to TestPyPI** manually from the Actions tab, then install from it (project from TestPyPI, dependencies from real PyPI): ```bash @@ -36,6 +36,7 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap pip install clinical-scope==X.Y.Z ``` → run `clinical-scope`, check the example. + → `clinical-scope --demo --force` — a pip install ships no example, and `--force` is what makes this a real check: without it the demo folder left by the previous release is kept and nothing is downloaded. **Note:** versions can't be reused — TestPyPI and PyPI both reject re-uploading a version that already exists. Bump to a `.devN` (e.g. `X.Y.Z.dev0`) if you need to re-run the TestPyPI dry-run. diff --git a/docs/adr/0008-datasource-modules-need-format-specific-parsing.md b/docs/adr/0008-datasource-modules-need-format-specific-parsing.md index ecc505b..6fc1385 100644 --- a/docs/adr/0008-datasource-modules-need-format-specific-parsing.md +++ b/docs/adr/0008-datasource-modules-need-format-specific-parsing.md @@ -12,7 +12,7 @@ The registry had grown to twelve datasources, and three of them — `philips_wav That was defensible when `other/` was a single undifferentiated bucket: every file in it shared one configuration block, so a file needing its own `time_shift` genuinely had nowhere to go but a module of its own. Per-file configuration (`other::`, see [ADR-0009](0009-other-stem-is-a-config-scope.md)) removed that constraint. Once each file inside `other/` carries its own scope, a module that only supplies configuration is a module that supplies nothing. -Leaving them cost more than the dead code. A datasource module is the unit contributors copy: the `/new-datasource` skill, the registry ordering rule, the per-source test and snapshot files, the tutorial table. Three modules whose only content was configuration taught every future contributor that "my CSV has a different time offset" is a reason to write one — which is how a registry reaches thirty entries that all call `read_csv`. +Leaving them cost more than the dead code. A datasource module is the unit contributors copy: the `/new-datasource` skill, the registry ordering rule, the per-source test and snapshot files, the user guide table. Three modules whose only content was configuration taught every future contributor that "my CSV has a different time offset" is a reason to write one — which is how a registry reaches thirty entries that all call `read_csv`. Three options were considered: diff --git a/docs/user_guide/build_pdf.sh b/docs/user_guide/build_pdf.sh index eedde66..e36cfbb 100755 --- a/docs/user_guide/build_pdf.sh +++ b/docs/user_guide/build_pdf.sh @@ -6,7 +6,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INPUT="$SCRIPT_DIR/tutorial.md" +INPUT="$SCRIPT_DIR/user_guide.md" OUTPUT="$SCRIPT_DIR/ClinicalScope_UserGuide.pdf" # Colors diff --git a/docs/user_guide/tutorial.md b/docs/user_guide/user_guide.md similarity index 97% rename from docs/user_guide/tutorial.md rename to docs/user_guide/user_guide.md index 03b88f9..8386aa8 100644 --- a/docs/user_guide/tutorial.md +++ b/docs/user_guide/user_guide.md @@ -103,6 +103,30 @@ The bundle also ships this user guide and a template folder for organizing patie To **close** ClinicalScope, close the terminal window that opened with it — the application runs inside that window. If the window is hidden, end the `ClinicalScope` process from your system's process manager. +## Trying the Demo Dataset + +ClinicalScope comes with a small demo recording — a single patient, with one example of every supported data source — so you can see a complete visualization before preparing any data of your own. + +**With the standalone application**, the demo is already there: look for the `demo_database/` folder next to the executable. + +**With a `pip` install**, the demo is not part of the package. Download it once from a terminal: + +```bash +clinical-scope --demo +``` + +The command prints the folder it downloaded into, along with the two paths the app asks for. It is safe to repeat: an already-downloaded demo is left alone. Run `clinical-scope --help` to see everything the command line offers. + +Then, in the application: + +1. Click **Default visualization (all sources)**, or upload the demo's `database_options.json`. +2. Set **Data folder** to the demo's `demo_patient` folder. +3. Set the EIT **day** to `2004-09-15`, and the EDF **recording start** to `2004-09-15 10:12:33`. Neither file carries the recording date and both expect you to supply one: EIT device files record time of day only, and the demo's EDF was de-identified, which blanks the date in its header. +4. Click **Inspect data** to check that every source was found and covers the period you asked for. This is worth doing on any new folder before plotting anything. +5. Click **Process visualization**. + +The demo is synthetic data for learning the interface. It is not a real recording, and nothing in it should be read clinically. + ## Application Overview The interface is organized top-to-bottom in the following order: @@ -122,7 +146,7 @@ The interface is organized top-to-bottom in the following order: status badges, column tables, and a CSV download. 6. **Visualization Area** -- Interactive plots. -A **⚙ Settings** button sits at the top right, above the Database Options row. It opens your personal display and export settings, which apply to every patient you open — see [Settings](#settings). +A **⚙ Settings** button sits at the top right, above the Database Options row. It opens your personal display and export settings, which apply to every patient you open — see [Settings](#settings). Below it, **📖 Docs** opens this guide in a new browser tab, always at its newest version. ![Application main interface](images/AppMainScreen.png){ width=100% } diff --git a/pyproject.toml b/pyproject.toml index 9db3b23..bacfe43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ ] [project.scripts] -clinical-scope = "clinical_scope.dash_api.core_api:main" +clinical-scope = "clinical_scope.cli:main" [project.optional-dependencies] # ruff is capped to a minor line: the formatter and new lint rules change in minors, and select = ["ALL"] opts into every rule ruff adds. @@ -63,4 +63,7 @@ markers = [ [project.urls] Homepage = "https://github.com/larib-data/clinical-scope" Repository = "https://github.com/larib-data/clinical-scope.git" +Documentation = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/user_guide.md" +Changelog = "https://github.com/larib-data/clinical-scope/blob/main/CHANGELOG.md" +Issues = "https://github.com/larib-data/clinical-scope/issues" DOI = "https://doi.org/10.5281/zenodo.20830140" diff --git a/ruff.toml b/ruff.toml index 168fafc..800429b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -72,6 +72,10 @@ unfixable = [] "src/clinical_scope/build_info/*" = [ "T201", # build CLI prints progress to the console ] +"src/clinical_scope/cli.py" = [ + "T201", # the console script's stdout is its interface + "PLC0415", # imports are deferred so --help and --demo never build the Dash layout +] [format] quote-style = "double" diff --git a/src/clinical_scope/build_info/README.md b/src/clinical_scope/build_info/README.md index 08c47a5..ed1f4d1 100644 --- a/src/clinical_scope/build_info/README.md +++ b/src/clinical_scope/build_info/README.md @@ -66,13 +66,13 @@ builded_app/ Everything above the `_internal/` line except the executable is copied in by `assemble_bundle.py`, from the `ASSETS` manifest at the top of that file. Adding a file to the bundle means adding it there — both build entry points read the same list. -**The user guide PDF is a committed artifact, not a build product.** `assemble_bundle.py` copies `docs/user_guide/ClinicalScope_UserGuide.pdf` as it finds it in the repo; nothing regenerates it from `tutorial.md`, and a stale PDF copies just as cleanly as a fresh one — the build cannot tell the difference and says nothing. Regenerating is a manual step: +**The user guide PDF is a committed artifact, not a build product.** `assemble_bundle.py` copies `docs/user_guide/ClinicalScope_UserGuide.pdf` as it finds it in the repo; nothing regenerates it from `user_guide.md`, and a stale PDF copies just as cleanly as a fresh one — the build cannot tell the difference and says nothing. Regenerating is a manual step: ```bash ./docs/user_guide/build_pdf.sh # needs pandoc + xelatex/pdflatex ``` -It is deliberately not wired into `build.sh`: pandoc and a LaTeX engine would then be prerequisites on every build machine, CI runners included, to rebuild a file that changes a few times a year. The cost of that choice is that **`tutorial.md` and the PDF drift silently**, so run the script and commit the result whenever you edit the tutorial — and always before cutting a release ([RELEASING.md](../../../docs/RELEASING.md) step 1). +It is deliberately not wired into `build.sh`: pandoc and a LaTeX engine would then be prerequisites on every build machine, CI runners included, to rebuild a file that changes a few times a year. The cost of that choice is that **`user_guide.md` and the PDF drift silently**, so run the script and commit the result whenever you edit the user guide — and always before cutting a release ([RELEASING.md](../../../docs/RELEASING.md) step 1). ## License notices diff --git a/src/clinical_scope/build_info/assemble_bundle.py b/src/clinical_scope/build_info/assemble_bundle.py index 1dc7ce2..749961e 100755 --- a/src/clinical_scope/build_info/assemble_bundle.py +++ b/src/clinical_scope/build_info/assemble_bundle.py @@ -43,7 +43,7 @@ ("example/template_patient_data_structure", "tree"), ("example/demo_database", "tree"), ] -_TREE_IGNORE = shutil.ignore_patterns("clinical_scope_output") +_TREE_IGNORE = shutil.ignore_patterns("clinical_scope_output", ".DS_Store", "__MACOSX") def copy_assets(bundle_root: Path) -> list[str]: diff --git a/src/clinical_scope/cli.py b/src/clinical_scope/cli.py new file mode 100644 index 0000000..9a4124b --- /dev/null +++ b/src/clinical_scope/cli.py @@ -0,0 +1,92 @@ +""" +The ``clinical-scope`` console script: launch the dashboard, or fetch the demo dataset. + +Separate from :mod:`clinical_scope.dash_api.core_api` because the two entry points have +different argv contracts. This one is run by a person who may pass flags; core_api is the +PyInstaller entry point, run by Finder, which hands a macOS bundle a ``-psn_…`` argument that +any parser would reject. + +Imports of ``dash_api`` are deferred to their call sites: it builds the Dash layout at import +time, so ``--help`` and ``--demo`` would otherwise pay for a layout they never render. +""" + +from __future__ import annotations + +import argparse +import sys + +import clinical_scope.constants as cst +from clinical_scope.demo_data import DemoDownloadError, fetch_demo_data + +_EPILOG = """\ +examples: + clinical-scope launch the dashboard at http://127.0.0.1:8050 + clinical-scope --demo download the demo dataset and print where it landed + +The demo folder holds a ready-made patient recording plus the database_options config +that goes with it: paste its demo_patient path into the app's Data folder field. +""" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="clinical-scope", + description="Interactive visualization dashboard for clinical physiological signals.", + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--demo", + action="store_true", + help="download the demo dataset, print its path and exit (does not start the app)", + ) + parser.add_argument( + "--force", + action="store_true", + help="with --demo, re-download even if the demo folder already exists", + ) + parser.add_argument("--version", action="version", version=_version_string()) + return parser + + +def _version_string() -> str: + # Deferred import — see the module docstring. + from clinical_scope.dash_api.version_check import running_version + + return f"clinical-scope {running_version()}" + + +def _download_demo(*, force: bool) -> int: + try: + folder = fetch_demo_data(force=force) + except DemoDownloadError as error: + print(f"clinical-scope: could not get the demo data: {error}", file=sys.stderr) + return 1 + + database = folder / cst.DEMO_DATABASE_DIR_NAME + print(f"Demo data ready at:\n {folder}\n") + print("Start the app with `clinical-scope`, then:") + print(f" - Data folder: {database / cst.DEMO_PATIENT_DIR_NAME}") + print(f" - Database options: {database / cst.DEMO_DATABASE_OPTIONS_FILE_NAME}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the ``clinical-scope`` script; returns the process exit code.""" + parser = _build_parser() + args = parser.parse_args(argv) + + if args.demo: + return _download_demo(force=args.force) + if args.force: + parser.error("--force only applies to --demo") + + # Deferred import — see the module docstring. + from clinical_scope.dash_api.core_api import main as run_dashboard + + run_dashboard() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/clinical_scope/constants.py b/src/clinical_scope/constants.py index 48d7306..152f648 100644 --- a/src/clinical_scope/constants.py +++ b/src/clinical_scope/constants.py @@ -67,6 +67,30 @@ UPDATE_AVAILABLE_LABEL = "| {version} available ↗" RELEASES_PAGE_LABEL = "| releases ↗" +# Target of the in-app Docs link. The newest release's PDF rather than the copy on `main`, +# which carries edits for a version nobody is running yet. +USER_GUIDE_URL = ( + "https://github.com/larib-data/clinical-scope/releases/latest/download/" + "ClinicalScope_UserGuide.pdf" +) + +# Pinned to `releases/latest` rather than the running version: the demo changes far more +# slowly than the app, and an older release carries no asset to fall back to. +DEMO_ARCHIVE_URL = ( + "https://github.com/larib-data/clinical-scope/releases/latest/download/" + "clinical-scope-example.zip" +) +DEMO_DIR_NAME = "example" # extracted under ~// +# Layout inside the archive, which is a copy of the checkout's example/ tree. The first two +# are what `--demo` prints for the app's Data folder and Database options fields. +DEMO_DATABASE_DIR_NAME = "demo_database" +DEMO_PATIENT_DIR_NAME = "demo_patient" +DEMO_DATABASE_OPTIONS_FILE_NAME = "database_options.json" +DEMO_DOWNLOAD_TIMEOUT_SECONDS = 30.0 +DEMO_DOWNLOAD_CHUNK_BYTES = 64 * 1024 +# The published archive is ~2 MB; this only bounds a response that never stops arriving. +DEMO_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 + PLACEHOLDER_TIMESTAMP = "YYYY-MM-DD HH:MM:SS" PLACEHOLDER_DAY = "YYYY-MM-DD" diff --git a/src/clinical_scope/dash_api/core_api.py b/src/clinical_scope/dash_api/core_api.py index af41e7e..3011793 100644 --- a/src/clinical_scope/dash_api/core_api.py +++ b/src/clinical_scope/dash_api/core_api.py @@ -46,6 +46,7 @@ INSPECTION_MODAL_PANEL, INSPECTION_MODAL_SCROLLABLE_BODY, INSPECTION_MODAL_STYLE_HIDDEN, + LINK_DOCS, ROOT_CONTAINER, SETTINGS_MODAL_PANEL, VERSION_BADGE, @@ -589,6 +590,14 @@ def _color_picker(swatch_type: str, input_id: str, preview_id: str) -> html.Div: interval=cst.UPDATE_CHECK_DELAY_MS, max_intervals=1, ), + html.A( + "📖 Docs", + id="docs-link", + href=cst.USER_GUIDE_URL, + target="_blank", + rel="noopener noreferrer", + style=LINK_DOCS, + ), html.Button("⚙ Settings", id="settings-open-btn", n_clicks=0, style=BUTTON_GEAR), _settings_modal, # Global user options store (source of truth for the settings surfaces). diff --git a/src/clinical_scope/dash_api/styles.py b/src/clinical_scope/dash_api/styles.py index dfe852b..642ca86 100644 --- a/src/clinical_scope/dash_api/styles.py +++ b/src/clinical_scope/dash_api/styles.py @@ -154,9 +154,12 @@ # --------------------------------------------------------------------------- # 5. Layout styles # --------------------------------------------------------------------------- +_STACK_TOP_PX = 10 +_STACK_PITCH_PX = 30 + VERSION_BADGE: dict = { "position": "absolute", - "top": "10px", + "top": f"{_STACK_TOP_PX}px", "right": "10px", "color": "#666", "fontSize": "12px", @@ -176,10 +179,10 @@ "fontWeight": "bold", } -# Settings pill — stacked directly under the version badge (top-right); badge-matching styling. +# Settings pill — last in the top-right stack; badge-matching styling. BUTTON_GEAR: dict = { "position": "absolute", - "top": "40px", + "top": f"{_STACK_TOP_PX + 2 * _STACK_PITCH_PX}px", "right": "10px", "cursor": "pointer", "fontSize": "12px", @@ -191,6 +194,14 @@ "color": "#666", } +# Grey like its neighbours: reaching the user guide is not one of the action roles the coloured +# buttons carry. +LINK_DOCS: dict = { + **BUTTON_GEAR, + "top": f"{_STACK_TOP_PX + _STACK_PITCH_PX}px", + "textDecoration": "none", +} + ROOT_CONTAINER: dict = { "padding": "20px 32px", "maxWidth": "1400px", diff --git a/src/clinical_scope/demo_data.py b/src/clinical_scope/demo_data.py new file mode 100644 index 0000000..354a46c --- /dev/null +++ b/src/clinical_scope/demo_data.py @@ -0,0 +1,130 @@ +""" +Download the demo dataset that a ``pip`` install does not ship. + +A source checkout has ``example/`` by definition and the standalone bundle carries a copy, so +this exists for the third channel only: a wheel packages ``src/`` and nothing else. The archive +is published as a release asset by ``build.yml``. + +Deliberately free of Dash imports, like :mod:`clinical_scope.dash_api.version_check`: the whole +download is testable without an app fixture, and every failure leaves by one exception type so +callers never have to enumerate what the network can do. +""" + +from __future__ import annotations + +import logging +import shutil +import tempfile +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + +import clinical_scope.constants as cst + +logger = logging.getLogger(__name__) + + +class DemoDownloadError(RuntimeError): + """The demo dataset could not be fetched or unpacked. Carries a user-readable reason.""" + + +def demo_folder() -> Path: + """Where the demo lives once fetched — beside the other app state under the user's home.""" + return Path.home() / cst.CLINICAL_SCOPE_DIR_NAME / cst.DEMO_DIR_NAME + + +def fetch_demo_data(*, force: bool = False) -> Path: + """ + Ensure the demo dataset is on disk and return its folder. + + A non-empty folder is left alone unless ``force``, so the command is safe to repeat and + costs nothing the second time. Raises :class:`DemoDownloadError` for every failure. + """ + destination = demo_folder() + if not force and destination.is_dir() and any(destination.iterdir()): + logger.info("Demo data already present at %s", destination) + return destination + + archive = _download(cst.DEMO_ARCHIVE_URL) + try: + _extract(archive, destination) + finally: + archive.unlink(missing_ok=True) + + logger.info("Demo data extracted to %s", destination) + return destination + + +def _download(url: str) -> Path: + """Stream ``url`` to a temporary file, bounded in size, and return its path.""" + handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) # noqa: SIM115 + archive = Path(handle.name) + try: + # Scheme is fixed by the constant, so the URL cannot be steered elsewhere. + with ( + handle, + urllib.request.urlopen( # noqa: S310 + url, timeout=cst.DEMO_DOWNLOAD_TIMEOUT_SECONDS + ) as response, + ): + received = 0 + while chunk := response.read(cst.DEMO_DOWNLOAD_CHUNK_BYTES): + received += len(chunk) + if received > cst.DEMO_MAX_ARCHIVE_BYTES: + # Raised inside the loop so the handler below deletes the part-file. + msg = f"the archive at {url} is larger than this version expects" + raise DemoDownloadError(msg) # noqa: TRY301 + handle.write(chunk) + except DemoDownloadError: + archive.unlink(missing_ok=True) + raise + except OSError as error: + archive.unlink(missing_ok=True) + # A release published without the asset looks exactly like being offline, so the + # message names both rather than guessing which one happened. + msg = ( + f"could not reach {url} ({error}). Check your connection, or download the archive " + "by hand from the latest release." + ) + raise DemoDownloadError(msg) from error + return archive + + +def _extract(archive: Path, destination: Path) -> None: + """ + Unpack ``archive`` into ``destination``, replacing whatever was there. + + Staged next door and moved into place at the end: an interrupted extraction must not leave + a half-filled folder, which :func:`fetch_demo_data` would then read as already complete. + """ + staging = destination.with_name(f"{destination.name}.part") + shutil.rmtree(staging, ignore_errors=True) + + try: + with zipfile.ZipFile(archive) as bundle: + _reject_escaping_members(bundle.namelist()) + staging.mkdir(parents=True) + # Safe because every member was just validated as staying inside the folder. + bundle.extractall(staging) + except (OSError, zipfile.BadZipFile) as error: + shutil.rmtree(staging, ignore_errors=True) + msg = f"the downloaded archive could not be unpacked ({error})" + raise DemoDownloadError(msg) from error + + shutil.rmtree(destination, ignore_errors=True) + staging.rename(destination) + + +def _reject_escaping_members(names: list[str]) -> None: + """Refuse any member that would write outside the destination (``..``, absolute, drive).""" + escaping = [ + name + for name in names + if PurePosixPath(name).is_absolute() + or ".." in PurePosixPath(name).parts + or "\\" in name + or (len(name) > 1 and name[1] == ":") + ] + if escaping: + msg = f"the archive contains unsafe path(s): {sorted(escaping)}" + raise DemoDownloadError(msg) diff --git a/tests/integration/test_output_root.py b/tests/integration/test_output_root.py index bee8bf3..7238682 100644 --- a/tests/integration/test_output_root.py +++ b/tests/integration/test_output_root.py @@ -71,7 +71,7 @@ def test_shared_root_same_name_overwrites(self, tmp_path): Documented limitation (ADR 0003): two *different* Databases that share a patient-folder name under the **same** output_root collapse onto one leaf, and the second write - overwrites the first. This pins the tutorial 'Known limitation' bullet as an executable + overwrites the first. This pins the user guide's 'Known limitation' bullet as an executable contract — if a future change disambiguated the leaf (e.g. a Database-name hash), this test would fail and force the doc to be updated in lockstep. """ diff --git a/tests/plot_types/test_plot_type_is_documented.py b/tests/plot_types/test_plot_type_is_documented.py index c1f0568..34c6418 100644 --- a/tests/plot_types/test_plot_type_is_documented.py +++ b/tests/plot_types/test_plot_type_is_documented.py @@ -5,13 +5,13 @@ validates, renders, ships — and is described nowhere a reader would look. That gap is the whole periphery a new plot type has to land in, and it is the only part of it left to prose. -Two audiences, so two documents, neither substituting for the other. The **tutorial** is where +Two audiences, so two documents, neither substituting for the other. The **user guide** is where a clinician learns the config key exists at all; ``CONTEXT.md`` is where the word the team says out loud is pinned to one meaning, so ``psd`` in a config file and "PSD" in a corridor conversation are the same thing. Deliberately anchored on *headings* and *glossary terms* rather than a search of the prose: -"loop" appears all over the tutorial for unrelated reasons -- the datasource loop, a loop +"loop" appears all over the user guide for unrelated reasons -- the datasource loop, a loop subplot's height, multi-cycle loops -- so a body search would pass for a plot type nobody had written a word about. A heading is a place in the document; a mention is not. @@ -28,7 +28,7 @@ from clinical_scope.plot_types import registry PROJECT_ROOT = Path(__file__).resolve().parents[2] -TUTORIAL = PROJECT_ROOT / "docs" / "user_guide" / "tutorial.md" +USER_GUIDE = PROJECT_ROOT / "docs" / "user_guide" / "user_guide.md" CONTEXT = PROJECT_ROOT / "CONTEXT.md" # A glossary entry is a bold term at the start of a line, followed by its definition. @@ -46,10 +46,10 @@ def _spellings(definition): @pytest.mark.parametrize("definition", registry.DERIVED, ids=lambda s: s.NAME) -def test_the_tutorial_gives_it_a_heading(definition): +def test_the_user_guide_gives_it_a_heading(definition): """Where a clinician finds out the key exists -- a config block, a sheet, or a section.""" headings = [ - line for line in TUTORIAL.read_text(encoding="utf-8").splitlines() if line.startswith("#") + line for line in USER_GUIDE.read_text(encoding="utf-8").splitlines() if line.startswith("#") ] spellings = _spellings(definition) @@ -60,7 +60,7 @@ def test_the_tutorial_gives_it_a_heading(definition): ] assert found, ( - f"No heading in docs/user_guide/tutorial.md names the {definition.NAME!r} plot type " + f"No heading in docs/user_guide/user_guide.md names the {definition.NAME!r} plot type " f"(looked for {sorted(spellings)}). Add the section a reader would need to configure " f"one -- the '`spectrogram` Block' and '`spectrograms` sheet' headings are the shape." ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..5fd5978 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,108 @@ +""" +Cover the ``clinical-scope`` console script: the argument surface a terminal user meets. + +Both things the parser can reach are replaced here — the download, and the Dash app, which is +built at import time and so must never be imported by a test. +""" + +import sys +import types + +import pytest + +from clinical_scope import cli +from clinical_scope.demo_data import DemoDownloadError + +DOWNLOAD_FOLDER_NAME = "example" + + +@pytest.fixture +def fetched(monkeypatch, tmp_path): + """Stand in for the download; returns the list of ``force`` values it was called with.""" + calls: list[bool] = [] + + def fake_fetch(*, force=False): + calls.append(force) + folder = tmp_path / DOWNLOAD_FOLDER_NAME + folder.mkdir(exist_ok=True) + return folder + + monkeypatch.setattr(cli, "fetch_demo_data", fake_fetch) + return calls + + +@pytest.fixture +def failing_fetch(monkeypatch): + def fake_fetch(*, force=False): + raise DemoDownloadError("could not reach the release") + + monkeypatch.setattr(cli, "fetch_demo_data", fake_fetch) + + +@pytest.fixture +def dashboard(monkeypatch): + """A stub ``core_api`` in ``sys.modules``, so launching never builds a real Dash app.""" + launches: list[bool] = [] + module = types.ModuleType("clinical_scope.dash_api.core_api") + module.main = lambda: launches.append(True) + monkeypatch.setitem(sys.modules, "clinical_scope.dash_api.core_api", module) + return launches + + +class TestLaunching: + """A bare invocation is still the ordinary way in.""" + + def test_no_arguments_starts_the_dashboard(self, dashboard): + assert cli.main([]) == 0 + assert dashboard == [True] + + def test_demo_does_not_start_the_dashboard(self, fetched, dashboard, capsys): + cli.main(["--demo"]) + + assert dashboard == [] + + +class TestDemoOutput: + """The printed paths are the only instructions a pip user gets; they must be the real ones.""" + + def test_it_prints_the_two_paths_the_app_asks_for(self, fetched, tmp_path, capsys): + exit_code = cli.main(["--demo"]) + + printed = capsys.readouterr().out + demo = tmp_path / DOWNLOAD_FOLDER_NAME / "demo_database" + assert exit_code == 0 + assert str(demo / "demo_patient") in printed + assert str(demo / "database_options.json") in printed + + def test_force_reaches_the_download(self, fetched): + cli.main(["--demo", "--force"]) + + assert fetched == [True] + + def test_without_force_the_download_may_reuse_what_is_there(self, fetched): + cli.main(["--demo"]) + + assert fetched == [False] + + def test_a_failed_download_is_a_message_not_a_traceback(self, failing_fetch, capsys): + exit_code = cli.main(["--demo"]) + + assert exit_code == 1 + assert "could not reach the release" in capsys.readouterr().err + + +class TestForceIsRefusedOnItsOwn: + """Accepting ``--force`` without ``--demo`` would silently do nothing.""" + + def test_force_without_demo_exits_with_usage(self, dashboard, capsys): + with pytest.raises(SystemExit) as exit_info: + cli.main(["--force"]) + + assert exit_info.value.code == 2 + assert "--force" in capsys.readouterr().err + + def test_force_without_demo_does_not_start_the_dashboard(self, dashboard): + with pytest.raises(SystemExit): + cli.main(["--force"]) + + assert dashboard == [] diff --git a/tests/unit/test_demo_data.py b/tests/unit/test_demo_data.py new file mode 100644 index 0000000..d40a10d --- /dev/null +++ b/tests/unit/test_demo_data.py @@ -0,0 +1,179 @@ +""" +Cover the demo download: the one code path a pip user hits before anything else works. + +Nothing here touches the network — ``urlopen`` is replaced by a fake serving bytes built in +the test. +""" + +import io +import urllib.error +import zipfile + +import pytest + +import clinical_scope.constants as cst +from clinical_scope import demo_data +from clinical_scope.demo_data import DemoDownloadError, demo_folder, fetch_demo_data + + +def _zip_bytes(members: dict[str, str]) -> bytes: + """A zip archive holding ``{name: text}``, as the release asset would be.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as bundle: + for name, text in members.items(): + bundle.writestr(name, text) + return buffer.getvalue() + + +DEMO_ARCHIVE = _zip_bytes( + { + "demo_database/database_options.json": "{}", + "demo_database/demo_patient/edf/eeg_001.edf": "fake", + "template_patient_data_structure/eit/.gitkeep": "", + } +) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """Redirect ``Path.home()`` so the real ``~/.clinical_scope`` is never touched.""" + monkeypatch.setattr(demo_data.Path, "home", staticmethod(lambda: tmp_path)) + return tmp_path + + +@pytest.fixture +def served(monkeypatch): + """Serve fixed bytes from ``urlopen`` and record every call made to it.""" + + def serve(payload: bytes | Exception): + calls = [] + + def fake_urlopen(url, timeout=None): + calls.append(url) + if isinstance(payload, Exception): + raise payload + return io.BytesIO(payload) + + monkeypatch.setattr(demo_data.urllib.request, "urlopen", fake_urlopen) + return calls + + return serve + + +class TestDemoFolder: + """Where the demo lands, given it has to be somewhere a pip install can write.""" + + def test_sits_beside_the_other_app_state(self, home): + folder = demo_folder() + + assert folder == home / ".clinical_scope" / "example" + + +class TestSuccessfulFetch: + """The happy path: an archive arrives and becomes a usable folder.""" + + def test_archive_is_unpacked(self, home, served): + served(DEMO_ARCHIVE) + + folder = fetch_demo_data() + + assert (folder / "demo_database" / "database_options.json").is_file() + assert (folder / "demo_database" / "demo_patient" / "edf" / "eeg_001.edf").is_file() + + def test_no_staging_folder_is_left_behind(self, home, served): + served(DEMO_ARCHIVE) + + folder = fetch_demo_data() + + assert not folder.with_name("example.part").exists() + + def test_an_existing_demo_is_not_downloaded_again(self, home, served): + calls = served(DEMO_ARCHIVE) + fetch_demo_data() + + fetch_demo_data() + + assert len(calls) == 1 + + def test_force_downloads_again(self, home, served): + calls = served(DEMO_ARCHIVE) + fetch_demo_data() + + fetch_demo_data(force=True) + + assert len(calls) == 2 + + def test_force_replaces_what_was_there(self, home, served): + served(DEMO_ARCHIVE) + folder = fetch_demo_data() + stale = folder / "demo_database" / "left_over.json" + stale.write_text("{}", encoding="utf-8") + + fetch_demo_data(force=True) + + assert not stale.exists() + + +class TestFailuresAreReadable: + """Every way this can fail leaves by one exception, with something a user can act on.""" + + def test_offline_names_the_url(self, home, served): + served(urllib.error.URLError("no route to host")) + + with pytest.raises(DemoDownloadError, match="releases/latest/download"): + fetch_demo_data() + + def test_missing_release_asset_is_not_a_traceback(self, home, served): + served(urllib.error.HTTPError(cst.DEMO_ARCHIVE_URL, 404, "Not Found", {}, None)) + + with pytest.raises(DemoDownloadError): + fetch_demo_data() + + def test_a_response_that_is_not_a_zip_is_rejected(self, home, served): + served(b"404") + + with pytest.raises(DemoDownloadError, match="could not be unpacked"): + fetch_demo_data() + + def test_an_oversized_response_stops_early(self, home, served, monkeypatch): + monkeypatch.setattr(cst, "DEMO_MAX_ARCHIVE_BYTES", 8) + served(DEMO_ARCHIVE) + + with pytest.raises(DemoDownloadError, match="larger than"): + fetch_demo_data() + + def test_a_failure_leaves_no_demo_folder(self, home, served): + served(b"not a zip") + + with pytest.raises(DemoDownloadError): + fetch_demo_data() + + assert not demo_folder().exists() + assert not demo_folder().with_name("example.part").exists() + + +class TestArchiveCannotEscapeItsFolder: + """A release asset is fetched over the network, so its member names are not trusted.""" + + @pytest.mark.parametrize( + "member", + [ + "../escaped.json", + "demo_database/../../escaped.json", + "/etc/passwd", + "..\\escaped.json", + ], + ) + def test_escaping_members_are_refused(self, home, served, member): + served(_zip_bytes({member: "x"})) + + with pytest.raises(DemoDownloadError, match="unsafe path"): + fetch_demo_data() + + def test_nothing_is_written_when_a_member_escapes(self, home, served): + served(_zip_bytes({"../escaped.json": "x"})) + + with pytest.raises(DemoDownloadError): + fetch_demo_data() + + assert not (home / "escaped.json").exists() diff --git a/tests/unit/test_example_assets.py b/tests/unit/test_example_assets.py index c793ae9..1ff9c06 100644 --- a/tests/unit/test_example_assets.py +++ b/tests/unit/test_example_assets.py @@ -104,3 +104,23 @@ def test_every_derived_plot_type_is_configured(self, project_root): f"{sorted(expected - configured)}. Add a sheet row to database_options.xlsx " f"naming demo signals that suit it, then regenerate the json.\n{REGENERATE_HINT}" ) + + +class TestDemoLayoutTheCliPrints: + """`clinical-scope --demo` unpacks a zip of this tree, then prints paths into it.""" + + def test_the_paths_the_demo_command_prints_exist(self, project_root): + demo = project_root / "example" / "demo_database" + + assert demo.is_dir(), ( + "`clinical-scope --demo` prints /demo_database/... — renaming this " + "folder needs cst.DEMO_DATABASE_DIR_NAME changed with it" + ) + assert (demo / "demo_patient").is_dir(), ( + "`clinical-scope --demo` prints this as the app's Data folder — see " + "cst.DEMO_PATIENT_DIR_NAME" + ) + assert (demo / "database_options.json").is_file(), ( + "`clinical-scope --demo` prints this as the app's Database options — see " + "cst.DEMO_DATABASE_OPTIONS_FILE_NAME" + ) diff --git a/tests/unit/test_readme_links.py b/tests/unit/test_readme_links.py new file mode 100644 index 0000000..4b88289 --- /dev/null +++ b/tests/unit/test_readme_links.py @@ -0,0 +1,38 @@ +""" +Guard ``README.md`` against links that only work on GitHub. + +The README is the wheel's ``long_description``, so PyPI renders it verbatim and resolves +relative URLs against ``pypi.org`` — where they 404, and images simply do not appear. For a +``pip install`` that page is the only entry point to the documentation, so a relative link +added here is a dead end for exactly the users who have no local copy of the repo. +""" + +import re + +# ``](target)`` — every inline markdown link and image. +MARKDOWN_TARGET = re.compile(r"\]\(([^)]+)\)") +# ``href="target"`` / ``src="target"`` — the README's centred badge block is raw HTML. +HTML_TARGET = re.compile(r'(?:href|src)="([^"]+)"') + +# Anchors stay relative: they resolve within the rendered page itself, on either site. +ALLOWED_PREFIXES = ("http://", "https://", "#", "mailto:") + + +def _targets(text: str) -> list[str]: + return MARKDOWN_TARGET.findall(text) + HTML_TARGET.findall(text) + + +class TestReadmeIsSelfContained: + """Nothing in the README may assume the reader has the repository on disk.""" + + def test_no_relative_links(self, project_root): + readme = (project_root / "README.md").read_text(encoding="utf-8") + + relative = [t for t in _targets(readme) if not t.startswith(ALLOWED_PREFIXES)] + + assert not relative, ( + f"README.md has repo-relative link(s): {sorted(set(relative))}. " + "PyPI resolves these against pypi.org and they 404. Use the absolute " + "https://github.com/larib-data/clinical-scope/blob/main/ form instead " + "(raw.githubusercontent.com for images)." + ) diff --git a/tests/unit/test_signal_container.py b/tests/unit/test_signal_container.py index 4640e01..e299fe5 100644 --- a/tests/unit/test_signal_container.py +++ b/tests/unit/test_signal_container.py @@ -234,7 +234,7 @@ def test_a_non_dict_block_is_ignored(self): assert sig.trace.mode == "lines" def test_per_signal_line_dash_still_wins(self): - """The signals block stays the last word, as the tutorial promises.""" + """The signals block stays the last word, as the user guide promises.""" sig = self._signal( database_options_specific={ "trace_options": {"line_dash": "solid"},