From 848906342c0840bffbc724e05186d507dc93cf45 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Wed, 16 Sep 2026 17:11:31 +0200 Subject: [PATCH 1/9] Make the documentation reachable from a pip install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `pip install clinical-scope` puts nothing but `src/` on disk: neither `docs/` nor `example/` is packaged, so the PyPI project page is the only entry point a pip user has. That page rendered the README with six repo-relative links, which PyPI resolves against pypi.org — every one of them 404'd, and the demo GIF did not render at all. The app itself offered no way to reach the tutorial either. - `[project.urls]` gains Documentation, Changelog and Issues, which Warehouse renders as the project page's sidebar. - Every README link is now absolute, so the page works off-repo. - A grey "Docs" pill joins the version badge and settings gear in the top-right stack, opening the online tutorial. - A test fails on any relative link reintroduced into the README. Co-Authored-By: Claude Opus 5 --- README.md | 16 +++++------ pyproject.toml | 4 +++ src/clinical_scope/constants.py | 4 +++ src/clinical_scope/dash_api/core_api.py | 10 +++++++ src/clinical_scope/dash_api/styles.py | 8 ++++++ tests/unit/test_readme_links.py | 38 +++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_readme_links.py diff --git a/README.md b/README.md index 958730f..6f148e1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python versions - + License: Apache 2.0 @@ -78,11 +78,11 @@ 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 @@ -94,7 +94,7 @@ For the full developer setup (tests, linting, adding a datasource), see [CONTRIB ## 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/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. ## Supported Data Sources @@ -110,7 +110,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/tutorial.md) → *Patient Data & Supported Data Sources* gives the folder keyword for each source, the naming rules, and the configuration details. ## Standalone Data Processing @@ -182,7 +182,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 +199,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 +215,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/pyproject.toml b/pyproject.toml index 9db3b23..76ebc23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,4 +63,8 @@ markers = [ [project.urls] Homepage = "https://github.com/larib-data/clinical-scope" Repository = "https://github.com/larib-data/clinical-scope.git" +# PyPI renders these as its sidebar: for a pip install, that page is the only entry point. +Documentation = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/tutorial.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/src/clinical_scope/constants.py b/src/clinical_scope/constants.py index 48d7306..2255d65 100644 --- a/src/clinical_scope/constants.py +++ b/src/clinical_scope/constants.py @@ -67,6 +67,10 @@ UPDATE_AVAILABLE_LABEL = "| {version} available ↗" RELEASES_PAGE_LABEL = "| releases ↗" +# Target of the in-app Docs link. Absolute rather than a repo path: a pip install ships no +# local copy of the tutorial, and the one bundled with the standalone app can be older. +TUTORIAL_URL = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/tutorial.md" + 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..a6c14f8 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, @@ -590,6 +591,15 @@ def _color_picker(swatch_type: str, input_id: str, preview_id: str) -> html.Div: max_intervals=1, ), html.Button("⚙ Settings", id="settings-open-btn", n_clicks=0, style=BUTTON_GEAR), + # The online tutorial, not a bundled file: a pip install has no local copy of it. + html.A( + "📖 Docs", + id="docs-link", + href=cst.TUTORIAL_URL, + target="_blank", + rel="noopener noreferrer", + style=LINK_DOCS, + ), _settings_modal, # Global user options store (source of truth for the settings surfaces). dcc.Store(id="user-options-store", data=_INITIAL_USER_OPTIONS), diff --git a/src/clinical_scope/dash_api/styles.py b/src/clinical_scope/dash_api/styles.py index dfe852b..71dce8d 100644 --- a/src/clinical_scope/dash_api/styles.py +++ b/src/clinical_scope/dash_api/styles.py @@ -191,6 +191,14 @@ "color": "#666", } +# Docs link — third pill in the top-right stack, under the settings gear. Grey like its +# neighbours: reaching the tutorial is not one of the action roles the coloured buttons carry. +LINK_DOCS: dict = { + **BUTTON_GEAR, + "top": "70px", + "textDecoration": "none", +} + ROOT_CONTAINER: dict = { "padding": "20px 32px", "maxWidth": "1400px", 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)." + ) From bdd5e41ea1d875deb890dc36a0d6997b397d6407 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Wed, 16 Sep 2026 17:12:49 +0200 Subject: [PATCH 2/9] Publish the example dataset as a release asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone bundle carries `example/` because assemble_bundle.py copies it in, and a source checkout has it by definition. A pip install has neither, so the demo data needs somewhere to be fetched from. Adds an `example` job to build.yml that zips `example/` into `clinical-scope-example.zip` and attaches it to the same draft release as the executables. The tree is staged through a filtered copy first, so the per-run parquet cache and macOS metadata stay out — 2.2 MB rather than the 10 MB an unfiltered archive of the folder would carry. `example/` itself does not move: the bundle keeps its own copy and the test fixtures keep their paths. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 44 +++++++++++++++++++++++++++++++++++++ .gitignore | 2 ++ 2 files changed, 46 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 817984c..60fe4ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,3 +99,47 @@ jobs: files: ${{ matrix.artifact_name }}.zip 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. + 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 intent as _TREE_IGNORE in assemble_bundle.py. + - 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..9cca8db 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ logs/ # Local testing and example **/clinical_scope_output/ +/example.zip +/clinical-scope-example.zip LOCAL_SCRIPT/* *_from_xlsx.json From d4af7865c4b9661e2847d01938fcc03e10200451 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Wed, 16 Sep 2026 17:16:19 +0200 Subject: [PATCH 3/9] Add `clinical-scope --demo` to fetch the demo dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pip install has no example data and, until now, no way to get any: the Quickstart pointed at a folder only a checkout or the standalone bundle has. `--demo` downloads the release asset into ~/.clinical_scope/example and prints the two paths the app asks for. The parser lives in a new `cli.py` rather than in `core_api.main`, which stays the PyInstaller entry point and must keep ignoring sys.argv — macOS hands a Finder-launched bundle a `-psn_…` argument that argparse would reject. Keeping it separate also means `--help` and `--demo` never build the Dash layout, which happens at import time. `clinical-scope -h` now describes the demo, so the command is discoverable from the terminal. The download is bounded in size, staged in a sibling folder so an interrupted run cannot leave a half-filled demo behind, and refuses any archive member that would write outside its destination. Every failure leaves as one exception: with no asset published yet, `--demo` reports a readable message and exits 1 rather than raising. Co-Authored-By: Claude Opus 5 --- docs/RELEASING.md | 8 +- pyproject.toml | 2 +- src/clinical_scope/cli.py | 84 +++++++++++++++ src/clinical_scope/constants.py | 13 +++ src/clinical_scope/demo_data.py | 123 ++++++++++++++++++++++ tests/unit/test_demo_data.py | 180 ++++++++++++++++++++++++++++++++ 6 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 src/clinical_scope/cli.py create mode 100644 src/clinical_scope/demo_data.py create mode 100644 tests/unit/test_demo_data.py diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1e1485e..42c0510 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -18,11 +18,13 @@ 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 **and the example archive** attached, no build warnings. + + `clinical-scope --demo` downloads that archive from `releases/latest/download/clinical-scope-example.zip`. It has to be on the newest release under exactly that name: publishing without it, or renaming it, breaks the demo command for every version already installed. 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 @@ -35,7 +37,7 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap ```bash pip install clinical-scope==X.Y.Z ``` - → run `clinical-scope`, check the example. + → run `clinical-scope`, check the example. A pip install ships none, so fetch it first with `clinical-scope --demo` — which also confirms the release asset is reachable. **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/pyproject.toml b/pyproject.toml index 76ebc23..e341a1d 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. diff --git a/src/clinical_scope/cli.py b/src/clinical_scope/cli.py new file mode 100644 index 0000000..887de55 --- /dev/null +++ b/src/clinical_scope/cli.py @@ -0,0 +1,84 @@ +""" +The ``clinical-scope`` console script: launch the dashboard, or fetch the demo dataset. + +Separate from :mod:`clinical_scope.dash_api.core_api`, which stays the PyInstaller entry point +and must keep ignoring ``sys.argv`` — macOS hands a Finder-launched bundle a ``-psn_…`` +argument that any parser would reject. Keeping the parsing here also means ``--help`` and +``--demo`` never pay for building the Dash layout, which happens at import time. +""" + +from __future__ import annotations + +import argparse +import sys + +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: + # Imported here so --version does not drag in the Dash layout with it. + 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 + + print(f"Demo data ready at:\n {folder}\n") + print("Start the app with `clinical-scope`, then:") + print(f" - Data folder: {folder / 'demo_database' / 'demo_patient'}") + print(f" - Database options: {folder / 'demo_database' / 'database_options.json'}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the ``clinical-scope`` script; returns the process exit code.""" + args = _build_parser().parse_args(argv) + + if args.demo: + return _download_demo(force=args.force) + + # Deferred: importing core_api builds the whole Dash app and opens the app log. + 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 2255d65..38e365d 100644 --- a/src/clinical_scope/constants.py +++ b/src/clinical_scope/constants.py @@ -71,6 +71,19 @@ # local copy of the tutorial, and the one bundled with the standalone app can be older. TUTORIAL_URL = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/tutorial.md" +# Demo dataset for `clinical-scope --demo`, which exists because a pip install packages no +# example data. 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 ~// +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/demo_data.py b/src/clinical_scope/demo_data.py new file mode 100644 index 0000000..b34fe4f --- /dev/null +++ b/src/clinical_scope/demo_data.py @@ -0,0 +1,123 @@ +""" +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: + raise DemoDownloadError( + f"the archive at {url} is larger than this version expects" + ) + 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. + raise DemoDownloadError( + f"could not reach {url} ({error}). Check your connection, or download the archive " + "by hand from the latest release." + ) 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) + bundle.extractall(staging) # noqa: S202 -- members validated just above + except (OSError, zipfile.BadZipFile) as error: + shutil.rmtree(staging, ignore_errors=True) + raise DemoDownloadError(f"the downloaded archive could not be unpacked ({error})") 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: + raise DemoDownloadError(f"the archive contains unsafe path(s): {sorted(escaping)}") diff --git a/tests/unit/test_demo_data.py b/tests/unit/test_demo_data.py new file mode 100644 index 0000000..e2e8541 --- /dev/null +++ b/tests/unit/test_demo_data.py @@ -0,0 +1,180 @@ +""" +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, which is the whole reason :mod:`clinical_scope.demo_data` keeps itself Dash-free and +takes its URL from a constant. +""" + +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() From 31c1857536a817d9c6f076f14049b644a89533a7 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Wed, 16 Sep 2026 17:17:58 +0200 Subject: [PATCH 4/9] Document how to get the demo data without a checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quickstart told every reader to point at `demo_database/demo_patient/`, a folder only a source checkout or the standalone application has. For a pip user that was a dead end with no alternative named. - README gains a *Trying the demo* section, and step 3 of the Quickstart sends readers to it instead of promising a bundled folder. - The user guide gains *Trying the Demo Dataset*, covering both routes — it is where the new in-app Docs link lands, so that is where someone who installed with pip will find out the demo is a download away. - The guide's interface tour mentions the Docs link next to Settings. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++++++++ README.md | 20 +++++++++++++++++--- docs/user_guide/tutorial.md | 25 ++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) 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/README.md b/README.md index 6f148e1..2270ad6 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,24 @@ For the full developer setup (tests, linting, adding a datasource), see [CONTRIB 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` so every source lines up) 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 and the standalone application already carry the same data under `example/demo_database/`. + +Run `clinical-scope --help` for the full list of commands. + ## Documentation The **[user guide](https://github.com/larib-data/clinical-scope/blob/main/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. @@ -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( diff --git a/docs/user_guide/tutorial.md b/docs/user_guide/tutorial.md index 03b88f9..a602689 100644 --- a/docs/user_guide/tutorial.md +++ b/docs/user_guide/tutorial.md @@ -103,6 +103,29 @@ 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 `example/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` — the demo's sources were recorded at different times, and these line them up on one timeline. +4. 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 +145,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% } From 29daef55c1a83db83dd128023d4e62e6bc62384c Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Thu, 17 Sep 2026 09:58:29 +0200 Subject: [PATCH 5/9] Fix what the three-axis review found on the demo/docs branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone bundle has no `example/` wrapper — assemble_bundle.py copies each asset to the bundle root — but the new demo sections in the README and the user guide sent standalone users to `example/demo_database/`. Both now name `demo_database/`, which is what is actually next to the executable. `--force` was declared at top level and read only inside the `--demo` branch, so `clinical-scope --force` launched the app and dropped the flag. It is now a usage error. `cli.py` also gains the tests it shipped without: the printed paths, both failure exits, and that neither `--demo` nor a refused `--force` ever reaches the dashboard. The demo tree's folder names moved to constants and are now pinned from both ends — test_cli.py asserts the literal paths the command prints, and test_example_assets.py asserts those same literals exist under `example/`. Renaming either side fails a test, instead of surfacing only once a tag is pushed and a user runs `--demo`. Two comments narrated how the code came to look as it does rather than what a reader needs: the docs-link style note restated its own dict, and the demo-download test docstring explained another module's design — untruly, since patching `urlopen` does not depend on that module being Dash-free. The top-right stack's 10/40/70px offsets became a top and a pitch. Also: assemble_bundle.py now filters the same names the release archive does, build.yml names the constant its asset name is pinned by, and the `.gitignore` entry for an `example.zip` nothing produces is gone. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 4 +- .gitignore | 1 - README.md | 2 +- docs/user_guide/tutorial.md | 2 +- .../build_info/assemble_bundle.py | 2 +- src/clinical_scope/cli.py | 15 ++- src/clinical_scope/constants.py | 5 + src/clinical_scope/dash_api/styles.py | 15 ++- tests/unit/test_cli.py | 108 ++++++++++++++++++ tests/unit/test_demo_data.py | 3 +- tests/unit/test_example_assets.py | 20 ++++ 11 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_cli.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60fe4ff..90ba6ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -103,6 +103,8 @@ jobs: # 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 @@ -111,7 +113,7 @@ jobs: - uses: actions/checkout@v4 # Staged through a filtered copy so the per-run parquet cache and macOS metadata stay out - # of the archive. Same intent as _TREE_IGNORE in assemble_bundle.py. + # 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' diff --git a/.gitignore b/.gitignore index 9cca8db..8bf939d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,6 @@ logs/ # Local testing and example **/clinical_scope_output/ -/example.zip /clinical-scope-example.zip LOCAL_SCRIPT/* *_from_xlsx.json diff --git a/README.md b/README.md index 2270ad6..34ec53d 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ A `pip install` does not include it, so download it once: 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 and the standalone application already carry the same data under `example/demo_database/`. +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. diff --git a/docs/user_guide/tutorial.md b/docs/user_guide/tutorial.md index a602689..4c9646c 100644 --- a/docs/user_guide/tutorial.md +++ b/docs/user_guide/tutorial.md @@ -107,7 +107,7 @@ To **close** ClinicalScope, close the terminal window that opened with it — th 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 `example/demo_database/` folder next to the executable. +**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: 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 index 887de55..04a722a 100644 --- a/src/clinical_scope/cli.py +++ b/src/clinical_scope/cli.py @@ -12,6 +12,7 @@ import argparse import sys +import clinical_scope.constants as cst from clinical_scope.demo_data import DemoDownloadError, fetch_demo_data _EPILOG = """\ @@ -46,7 +47,7 @@ def _build_parser() -> argparse.ArgumentParser: def _version_string() -> str: - # Imported here so --version does not drag in the Dash layout with it. + # Deferred import — see the module docstring. from clinical_scope.dash_api.version_check import running_version return f"clinical-scope {running_version()}" @@ -59,21 +60,25 @@ def _download_demo(*, force: bool) -> int: 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: {folder / 'demo_database' / 'demo_patient'}") - print(f" - Database options: {folder / 'demo_database' / 'database_options.json'}") + 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.""" - args = _build_parser().parse_args(argv) + 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: importing core_api builds the whole Dash app and opens the app log. + # Deferred import — see the module docstring. from clinical_scope.dash_api.core_api import main as run_dashboard run_dashboard() diff --git a/src/clinical_scope/constants.py b/src/clinical_scope/constants.py index 38e365d..5327ec2 100644 --- a/src/clinical_scope/constants.py +++ b/src/clinical_scope/constants.py @@ -79,6 +79,11 @@ "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. diff --git a/src/clinical_scope/dash_api/styles.py b/src/clinical_scope/dash_api/styles.py index 71dce8d..4e82f5c 100644 --- a/src/clinical_scope/dash_api/styles.py +++ b/src/clinical_scope/dash_api/styles.py @@ -154,9 +154,14 @@ # --------------------------------------------------------------------------- # 5. Layout styles # --------------------------------------------------------------------------- +# The top-right stack — version badge, settings gear, docs link — sits at a fixed pitch so a +# fourth pill is one more multiple rather than another hand-picked offset. +_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", @@ -179,7 +184,7 @@ # Settings pill — stacked directly under the version badge (top-right); badge-matching styling. BUTTON_GEAR: dict = { "position": "absolute", - "top": "40px", + "top": f"{_STACK_TOP_PX + _STACK_PITCH_PX}px", "right": "10px", "cursor": "pointer", "fontSize": "12px", @@ -191,11 +196,11 @@ "color": "#666", } -# Docs link — third pill in the top-right stack, under the settings gear. Grey like its -# neighbours: reaching the tutorial is not one of the action roles the coloured buttons carry. +# Grey like its neighbours: reaching the tutorial is not one of the action roles the coloured +# buttons carry. LINK_DOCS: dict = { **BUTTON_GEAR, - "top": "70px", + "top": f"{_STACK_TOP_PX + 2 * _STACK_PITCH_PX}px", "textDecoration": "none", } 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 index e2e8541..d40a10d 100644 --- a/tests/unit/test_demo_data.py +++ b/tests/unit/test_demo_data.py @@ -2,8 +2,7 @@ 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, which is the whole reason :mod:`clinical_scope.demo_data` keeps itself Dash-free and -takes its URL from a constant. +the test. """ import io 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" + ) From d3c46294d1002f93ae121189f9c65a38d344455e Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Thu, 17 Sep 2026 10:03:41 +0200 Subject: [PATCH 6/9] Make the branch pass ruff again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ruff check .` and `ruff format --check .` have both been failing since `cli.py` and `demo_data.py` were added, so CI's lint job was red for reasons unrelated to what the code does. `demo_data.py` now raises the way the rest of `src/` does — `msg = …` then `raise DemoDownloadError(msg)` — which is what TRY003 and EM102 are asking for, and incidentally puts the over-long unpack message inside the line limit. The `# noqa: S202` on `extractall` was never doing anything: S202 is a tarfile rule, so the reason it carried is now a plain comment. The one raise that must happen inside the `try`, so the handler deletes the part-file, keeps a narrow `# noqa: TRY301`. `cli.py` gets the per-file ignores the config already grants `scripts/*` and `build_info/*`: a console script prints to stdout by design, and its imports are deferred on purpose so `--help` and `--demo` never build the Dash layout. Co-Authored-By: Claude Opus 5 --- ruff.toml | 4 ++++ src/clinical_scope/demo_data.py | 29 ++++++++++++++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) 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/demo_data.py b/src/clinical_scope/demo_data.py index b34fe4f..354a46c 100644 --- a/src/clinical_scope/demo_data.py +++ b/src/clinical_scope/demo_data.py @@ -61,16 +61,19 @@ def _download(url: str) -> Path: 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: + 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: - raise DemoDownloadError( - f"the archive at {url} is larger than this version expects" - ) + # 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) @@ -79,10 +82,11 @@ def _download(url: str) -> Path: 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. - raise DemoDownloadError( + msg = ( f"could not reach {url} ({error}). Check your connection, or download the archive " "by hand from the latest release." - ) from error + ) + raise DemoDownloadError(msg) from error return archive @@ -100,10 +104,12 @@ def _extract(archive: Path, destination: Path) -> None: with zipfile.ZipFile(archive) as bundle: _reject_escaping_members(bundle.namelist()) staging.mkdir(parents=True) - bundle.extractall(staging) # noqa: S202 -- members validated just above + # 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) - raise DemoDownloadError(f"the downloaded archive could not be unpacked ({error})") from error + msg = f"the downloaded archive could not be unpacked ({error})" + raise DemoDownloadError(msg) from error shutil.rmtree(destination, ignore_errors=True) staging.rename(destination) @@ -120,4 +126,5 @@ def _reject_escaping_members(names: list[str]) -> None: or (len(name) > 1 and name[1] == ":") ] if escaping: - raise DemoDownloadError(f"the archive contains unsafe path(s): {sorted(escaping)}") + msg = f"the archive contains unsafe path(s): {sorted(escaping)}" + raise DemoDownloadError(msg) From 089e024398576b765a67b436af0a6a40acf93077 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Thu, 17 Sep 2026 10:03:48 +0200 Subject: [PATCH 7/9] Add a local run of the CI checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint job on this branch was red for four commits without anyone noticing, because running the checks meant remembering three commands and which venv had the pinned ruff. `.github/ci-local.sh` runs what `ci.yml` runs, next to the file it mirrors. It keeps CI's shape: every check runs and the failures are reported together, since CI splits lint and test into two jobs precisely so one cannot hide the other. It prints the ruff version first, as CI does, and says so when that version is outside the capped 0.16.x line — the mismatch CONTRIBUTING already warns about. It covers the active interpreter only; CI still runs the suite on 3.11 and 3.13. The PR checklist now points at it instead of listing the commands. Co-Authored-By: Claude Opus 5 --- .github/ci-local.sh | 44 ++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 3 +-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100755 .github/ci-local.sh diff --git a/.github/ci-local.sh b/.github/ci-local.sh new file mode 100755 index 0000000..b25eeae --- /dev/null +++ b/.github/ci-local.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Run what CI runs, before pushing. Mirrors ci.yml next door, including its split into +# independent groups: CI has two jobs so a lint failure cannot hide a test failure, so this +# runs every check and reports them together rather than stopping at the first one. +# +# CI runs the suite on Python 3.11 and 3.13; this covers whichever interpreter is active. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 + +bold=$(tput bold 2>/dev/null || true) +reset=$(tput sgr0 2>/dev/null || true) +failed="" + +run() { + local name="$1" + shift + printf '\n%s==> %s%s\n' "$bold" "$name" "$reset" + "$@" || failed="$failed '$name'" +} + +# Recorded first, as in CI: ruff is capped to one minor line because select = ["ALL"] opts into +# every rule a new one adds, so a version mismatch is what to check when a green branch reddens. +if ! version=$(ruff --version 2>/dev/null); then + echo "ruff not found — activate the project venv, then: pip install -e .[dev]" >&2 + exit 1 +fi +echo "$version" +case "$version" in + "ruff 0.16."*) ;; + *) echo "${bold}warning:${reset} CI pins ruff to 0.16.x — '$version' will disagree with it" >&2 ;; +esac + +run "Ruff format check" ruff format --check . +run "Ruff lint check" ruff check . +run "Tests" pytest + +if [ -z "$failed" ]; then + printf '\n%sAll CI checks passed.%s\n' "$bold" "$reset" + exit 0 +fi +printf '\n%sFailed:%s%s\n' "$bold" "$failed" "$reset" +exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebdc100..2d12ac9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,8 +70,7 @@ Then use the `/new-datasource` skill from within Claude Code — it walks throug **Branch naming:** `/` — e.g. `feat/mindray-ecg`, `fix/eit-timezone`, `docs/contributing`. **Before opening a PR:** -- All tests pass (`pytest`) -- Linting is clean (`ruff check .` and `ruff format --check .`) +- [`./.github/ci-local.sh`](.github/ci-local.sh) passes — it runs the three checks CI runs (`ruff format --check .`, `ruff check .`, `pytest`) against your active interpreter, and reports all three rather than stopping at the first failure. CI additionally runs the suite on both Python 3.11 and 3.13. - New datasources include example data and snapshot tests **PR description should include:** From 9a9a2ce3eadb8acb8f19b097df529087216c8eab Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Thu, 17 Sep 2026 11:45:47 +0200 Subject: [PATCH 8/9] Apply the PR 105 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops `.github/ci-local.sh` and restores the CONTRIBUTING checklist: running the checks locally already works with the plain commands, and a mirror of ci.yml takes on its job of being one controlled environment for everyone. Corrects the demo walkthrough. The EIT day and the EDF recording start are not there to line up recordings made at different times — neither file carries a recording date at all, so both formats expect one to be supplied. As written it read as a defect in the data. The walkthrough also gains an Inspect data step, which is the cheaper first look at any new folder. Points the in-app Docs link at `ClinicalScope_UserGuide.pdf` on the newest release rather than the markdown on `main`, and adds the build.yml job that attaches it, so a click lands on a released guide instead of edits for a version nobody is running. `TUTORIAL_URL` becomes `USER_GUIDE_URL`. Moves the Docs pill between the version badge and the settings gear. Removes the comments the review called narration, in core_api, styles, pyproject and constants. The release-asset naming warning leaves RELEASING entirely: build.yml already carries it, next to the name it pins, where the person who would break it is actually reading. Rewrites cli.py's docstring to lead with the reason the module exists — the console script and the Finder-launched bundle have different argv contracts — rather than with the startup cost, which only justifies the deferred imports and is now stated where those happen. RELEASING step 5 gets `--demo --force`: without --force the demo folder left by the previous release is kept, so nothing is downloaded and the asset is never actually checked. Co-Authored-By: Claude Opus 5 --- .github/ci-local.sh | 44 ------------------------- .github/workflows/build.yml | 20 +++++++++++ CLAUDE.md | 2 +- CONTRIBUTING.md | 3 +- README.md | 2 +- docs/RELEASING.md | 7 ++-- docs/user_guide/tutorial.md | 5 +-- pyproject.toml | 1 - src/clinical_scope/cli.py | 11 ++++--- src/clinical_scope/constants.py | 14 ++++---- src/clinical_scope/dash_api/core_api.py | 5 ++- src/clinical_scope/dash_api/styles.py | 8 ++--- 12 files changed, 50 insertions(+), 72 deletions(-) delete mode 100755 .github/ci-local.sh diff --git a/.github/ci-local.sh b/.github/ci-local.sh deleted file mode 100755 index b25eeae..0000000 --- a/.github/ci-local.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# -# Run what CI runs, before pushing. Mirrors ci.yml next door, including its split into -# independent groups: CI has two jobs so a lint failure cannot hide a test failure, so this -# runs every check and reports them together rather than stopping at the first one. -# -# CI runs the suite on Python 3.11 and 3.13; this covers whichever interpreter is active. - -set -uo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 - -bold=$(tput bold 2>/dev/null || true) -reset=$(tput sgr0 2>/dev/null || true) -failed="" - -run() { - local name="$1" - shift - printf '\n%s==> %s%s\n' "$bold" "$name" "$reset" - "$@" || failed="$failed '$name'" -} - -# Recorded first, as in CI: ruff is capped to one minor line because select = ["ALL"] opts into -# every rule a new one adds, so a version mismatch is what to check when a green branch reddens. -if ! version=$(ruff --version 2>/dev/null); then - echo "ruff not found — activate the project venv, then: pip install -e .[dev]" >&2 - exit 1 -fi -echo "$version" -case "$version" in - "ruff 0.16."*) ;; - *) echo "${bold}warning:${reset} CI pins ruff to 0.16.x — '$version' will disagree with it" >&2 ;; -esac - -run "Ruff format check" ruff format --check . -run "Ruff lint check" ruff check . -run "Tests" pytest - -if [ -z "$failed" ]; then - printf '\n%sAll CI checks passed.%s\n' "$bold" "$reset" - exit 0 -fi -printf '\n%sFailed:%s%s\n' "$bold" "$failed" "$reset" -exit 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90ba6ae..7f8d1c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,6 +100,26 @@ jobs: 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. diff --git a/CLAUDE.md b/CLAUDE.md index e0e271d..bb6e73a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ Gitignored under `logs/`: `logs/app/dash_api.log` (app), `logs/scripts/` (script - **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`. +- **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`. `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 2d12ac9..ebdc100 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,8 @@ Then use the `/new-datasource` skill from within Claude Code — it walks throug **Branch naming:** `/` — e.g. `feat/mindray-ecg`, `fix/eit-timezone`, `docs/contributing`. **Before opening a PR:** -- [`./.github/ci-local.sh`](.github/ci-local.sh) passes — it runs the three checks CI runs (`ruff format --check .`, `ruff check .`, `pytest`) against your active interpreter, and reports all three rather than stopping at the first failure. CI additionally runs the suite on both Python 3.11 and 3.13. +- All tests pass (`pytest`) +- Linting is clean (`ruff check .` and `ruff format --check .`) - New datasources include example data and snapshot tests **PR description should include:** diff --git a/README.md b/README.md index 34ec53d..fe45919 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ For the full developer setup (tests, linting, adding a datasource), see [CONTRIB 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. 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` 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** diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 42c0510..028eccc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -22,9 +22,7 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap ```bash git tag vX.Y.Z && git push origin vX.Y.Z ``` - → review the draft Release: executables **and the example archive** attached, no build warnings. - - `clinical-scope --demo` downloads that archive from `releases/latest/download/clinical-scope-example.zip`. It has to be on the newest release under exactly that name: publishing without it, or renaming it, breaks the demo command for every version already installed. + → 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 @@ -37,7 +35,8 @@ Release checklist for `clinical-scope`, starting from a `main` branch you're hap ```bash pip install clinical-scope==X.Y.Z ``` - → run `clinical-scope`, check the example. A pip install ships none, so fetch it first with `clinical-scope --demo` — which also confirms the release asset is reachable. + → 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/user_guide/tutorial.md b/docs/user_guide/tutorial.md index 4c9646c..8386aa8 100644 --- a/docs/user_guide/tutorial.md +++ b/docs/user_guide/tutorial.md @@ -121,8 +121,9 @@ 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` — the demo's sources were recorded at different times, and these line them up on one timeline. -4. Click **Process visualization**. +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. diff --git a/pyproject.toml b/pyproject.toml index e341a1d..7a2bdaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ markers = [ [project.urls] Homepage = "https://github.com/larib-data/clinical-scope" Repository = "https://github.com/larib-data/clinical-scope.git" -# PyPI renders these as its sidebar: for a pip install, that page is the only entry point. Documentation = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/tutorial.md" Changelog = "https://github.com/larib-data/clinical-scope/blob/main/CHANGELOG.md" Issues = "https://github.com/larib-data/clinical-scope/issues" diff --git a/src/clinical_scope/cli.py b/src/clinical_scope/cli.py index 04a722a..9a4124b 100644 --- a/src/clinical_scope/cli.py +++ b/src/clinical_scope/cli.py @@ -1,10 +1,13 @@ """ The ``clinical-scope`` console script: launch the dashboard, or fetch the demo dataset. -Separate from :mod:`clinical_scope.dash_api.core_api`, which stays the PyInstaller entry point -and must keep ignoring ``sys.argv`` — macOS hands a Finder-launched bundle a ``-psn_…`` -argument that any parser would reject. Keeping the parsing here also means ``--help`` and -``--demo`` never pay for building the Dash layout, which happens at import time. +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 diff --git a/src/clinical_scope/constants.py b/src/clinical_scope/constants.py index 5327ec2..152f648 100644 --- a/src/clinical_scope/constants.py +++ b/src/clinical_scope/constants.py @@ -67,13 +67,15 @@ UPDATE_AVAILABLE_LABEL = "| {version} available ↗" RELEASES_PAGE_LABEL = "| releases ↗" -# Target of the in-app Docs link. Absolute rather than a repo path: a pip install ships no -# local copy of the tutorial, and the one bundled with the standalone app can be older. -TUTORIAL_URL = "https://github.com/larib-data/clinical-scope/blob/main/docs/user_guide/tutorial.md" +# 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" +) -# Demo dataset for `clinical-scope --demo`, which exists because a pip install packages no -# example data. 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. +# 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" diff --git a/src/clinical_scope/dash_api/core_api.py b/src/clinical_scope/dash_api/core_api.py index a6c14f8..3011793 100644 --- a/src/clinical_scope/dash_api/core_api.py +++ b/src/clinical_scope/dash_api/core_api.py @@ -590,16 +590,15 @@ def _color_picker(swatch_type: str, input_id: str, preview_id: str) -> html.Div: interval=cst.UPDATE_CHECK_DELAY_MS, max_intervals=1, ), - html.Button("⚙ Settings", id="settings-open-btn", n_clicks=0, style=BUTTON_GEAR), - # The online tutorial, not a bundled file: a pip install has no local copy of it. html.A( "📖 Docs", id="docs-link", - href=cst.TUTORIAL_URL, + 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). dcc.Store(id="user-options-store", data=_INITIAL_USER_OPTIONS), diff --git a/src/clinical_scope/dash_api/styles.py b/src/clinical_scope/dash_api/styles.py index 4e82f5c..953437b 100644 --- a/src/clinical_scope/dash_api/styles.py +++ b/src/clinical_scope/dash_api/styles.py @@ -154,8 +154,6 @@ # --------------------------------------------------------------------------- # 5. Layout styles # --------------------------------------------------------------------------- -# The top-right stack — version badge, settings gear, docs link — sits at a fixed pitch so a -# fourth pill is one more multiple rather than another hand-picked offset. _STACK_TOP_PX = 10 _STACK_PITCH_PX = 30 @@ -181,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": f"{_STACK_TOP_PX + _STACK_PITCH_PX}px", + "top": f"{_STACK_TOP_PX + 2 * _STACK_PITCH_PX}px", "right": "10px", "cursor": "pointer", "fontSize": "12px", @@ -200,7 +198,7 @@ # buttons carry. LINK_DOCS: dict = { **BUTTON_GEAR, - "top": f"{_STACK_TOP_PX + 2 * _STACK_PITCH_PX}px", + "top": f"{_STACK_TOP_PX + _STACK_PITCH_PX}px", "textDecoration": "none", } From c8c05f83978f929a66d68b63c0bcbeadf99ba884 Mon Sep 17 00:00:00 2001 From: Alexis Janin Date: Thu, 17 Sep 2026 11:46:12 +0200 Subject: [PATCH 9/9] Settle on "user guide" over "tutorial" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document already called itself one thing and was filed under another: its own frontmatter title is "Clinical Scope -- User Guide", it builds to ClinicalScope_UserGuide.pdf and README linked it as "the user guide", while the file was tutorial.md and CLAUDE.md, CONTRIBUTING and the skills all called it the tutorial. Two names for one document, and a reader tracing a reference had to work out they were the same. Renames it to docs/user_guide/user_guide.md and settles every reference on "user guide" — links, prose, the build script, three skills, ADR-0008, and the test that reads the file by path. CHANGELOG entries keep saying "tutorial": they record what shipped in past releases, and that is a history rather than an inconsistency. Co-Authored-By: Claude Opus 5 --- .claude/skills/generate-database-options/SKILL.md | 4 ++-- .claude/skills/new-datasource/SKILL.md | 4 ++-- .claude/skills/new-plot-type/SKILL.md | 4 ++-- CLAUDE.md | 12 ++++++------ CONTRIBUTING.md | 2 +- README.md | 4 ++-- docs/RELEASING.md | 2 +- ...atasource-modules-need-format-specific-parsing.md | 2 +- docs/user_guide/build_pdf.sh | 2 +- docs/user_guide/{tutorial.md => user_guide.md} | 0 pyproject.toml | 2 +- src/clinical_scope/build_info/README.md | 4 ++-- src/clinical_scope/dash_api/styles.py | 2 +- tests/integration/test_output_root.py | 2 +- tests/plot_types/test_plot_type_is_documented.py | 12 ++++++------ tests/unit/test_signal_container.py | 2 +- 16 files changed, 30 insertions(+), 30 deletions(-) rename docs/user_guide/{tutorial.md => user_guide.md} (100%) 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/CLAUDE.md b/CLAUDE.md index bb6e73a..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`. `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/`). +- **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 fe45919..a756037 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Run `clinical-scope --help` for the full list of commands. ## Documentation -The **[user guide](https://github.com/larib-data/clinical-scope/blob/main/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 @@ -124,7 +124,7 @@ The **[user guide](https://github.com/larib-data/clinical-scope/blob/main/docs/u | 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](https://github.com/larib-data/clinical-scope/blob/main/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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 028eccc..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.** 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 100% rename from docs/user_guide/tutorial.md rename to docs/user_guide/user_guide.md diff --git a/pyproject.toml b/pyproject.toml index 7a2bdaf..bacfe43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +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/tutorial.md" +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/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/dash_api/styles.py b/src/clinical_scope/dash_api/styles.py index 953437b..642ca86 100644 --- a/src/clinical_scope/dash_api/styles.py +++ b/src/clinical_scope/dash_api/styles.py @@ -194,7 +194,7 @@ "color": "#666", } -# Grey like its neighbours: reaching the tutorial is not one of the action roles the coloured +# Grey like its neighbours: reaching the user guide is not one of the action roles the coloured # buttons carry. LINK_DOCS: dict = { **BUTTON_GEAR, 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_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"},