From 25ba55bc25c2d8d57d2ce2eaa37e91a24a48a74a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 20:40:15 +0100 Subject: [PATCH 01/15] feat: unified docs for the pymmcore-plus ecosystem Builds one zensical site from the docs of pymmcore-plus, useq-schema, pymmcore-widgets, pymmcore-gui and napari-micromanager: one navigation, one search, cross-references between all packages, a combined user guide, and a de-duplicated, generated API reference. Each member keeps its docs in its own repository; tools/compose.py assembles them (a stopgap until zensical supports composing projects natively). Includes the landing page, Getting Started, theme overrides, CSS/JS (method explorer, install builder), redirects for moved pages, and CI to deploy the site and publish pull-request previews. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 99 +++ .github/workflows/deploy.yml | 59 ++ .github/workflows/preview.yml | 54 ++ .gitignore | 9 + .pre-commit-config.yaml | 7 + .python-version | 1 + README.md | 89 +++ compose.overrides.toml | 9 + compose.toml | 36 ++ docs/.nav.yml | 58 ++ docs/api/index.md | 50 ++ docs/assets/css/pymm.css | 388 ++++++++++++ docs/assets/img/favicon.ico | Bin 0 -> 15406 bytes docs/assets/js/pymm.js | 192 ++++++ docs/contributing/docs.md | 98 +++ docs/contributing/index.md | 49 ++ docs/getting-started/first-acquisition.md | 99 +++ docs/getting-started/first-gui.md | 97 +++ docs/getting-started/first-steps.md | 103 ++++ docs/getting-started/index.md | 62 ++ docs/getting-started/install.md | 77 +++ docs/guide/index.md | 65 ++ docs/index.md | 140 +++++ index.html | 5 - justfile | 21 + overrides/main.html | 7 + overrides/partials/actions.html | 26 + overrides/partials/content.html | 25 + pyproject.toml | 38 ++ redirects.toml | 48 ++ tools/api.py | 367 +++++++++++ tools/check_urls.py | 74 +++ tools/compose.py | 685 +++++++++++++++++++++ tools/old_sitemaps/napari-micromanager.xml | 11 + tools/old_sitemaps/pymmcore-plus.xml | 123 ++++ tools/old_sitemaps/pymmcore-widgets.xml | 139 +++++ tools/old_sitemaps/useq-schema.xml | 31 + tools/pymm_mdx.py | 92 +++ tools/repos.py | 108 ++++ zensical.base.toml | 198 ++++++ 40 files changed, 3834 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/preview.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 README.md create mode 100644 compose.overrides.toml create mode 100644 compose.toml create mode 100644 docs/.nav.yml create mode 100644 docs/api/index.md create mode 100644 docs/assets/css/pymm.css create mode 100644 docs/assets/img/favicon.ico create mode 100644 docs/assets/js/pymm.js create mode 100644 docs/contributing/docs.md create mode 100644 docs/contributing/index.md create mode 100644 docs/getting-started/first-acquisition.md create mode 100644 docs/getting-started/first-gui.md create mode 100644 docs/getting-started/first-steps.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/install.md create mode 100644 docs/guide/index.md create mode 100644 docs/index.md delete mode 100644 index.html create mode 100644 justfile create mode 100644 overrides/main.html create mode 100644 overrides/partials/actions.html create mode 100644 overrides/partials/content.html create mode 100644 pyproject.toml create mode 100644 redirects.toml create mode 100644 tools/api.py create mode 100644 tools/check_urls.py create mode 100644 tools/compose.py create mode 100644 tools/old_sitemaps/napari-micromanager.xml create mode 100644 tools/old_sitemaps/pymmcore-plus.xml create mode 100644 tools/old_sitemaps/pymmcore-widgets.xml create mode 100644 tools/old_sitemaps/useq-schema.xml create mode 100644 tools/pymm_mdx.py create mode 100644 tools/repos.py create mode 100644 zensical.base.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c7cc9ae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,99 @@ +# Build the unified docs site (reusable). +# +# Called by: +# - preview.yml, for pull requests to this repo (once per OS; the macOS build is +# published as a browsable preview). +# - each member repository's docs.yml, for their pull requests: the whole site is +# built with that member at the PR's commit, and only warnings from that +# member's pages fail the build. +# +# The built site and the widget screenshots are uploaded as artifacts, and the +# job summary reports how long setup and the build took. +name: build + +on: + workflow_dispatch: + workflow_call: + inputs: + os: + description: "Runner to build on" + type: string + default: macos-latest + member: + description: "Member (name in compose.toml) to build at `repository@ref`" + type: string + default: "" + repository: + description: "Repository (owner/name) to check that member out from" + type: string + default: "" + ref: + description: "Commit or branch of that member" + type: string + default: "" + +jobs: + build: + runs-on: ${{ inputs.os || 'macos-latest' }} + defaults: + run: + shell: bash + env: + PYTHONUTF8: "1" + steps: + - run: echo "T0=$SECONDS" >> "$GITHUB_ENV" + + - uses: actions/checkout@v6 + with: + # when called from a member repo, check out the hub (not the caller) + repository: ${{ inputs.member && 'pymmcore-plus/pymmcore-plus.github.io' || github.repository }} + ref: ${{ inputs.member && 'main' || '' }} + + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + + - name: Check out members + run: > + uv run --no-project tools/repos.py + ${{ inputs.member && format('--pr {0}={1}@{2}', inputs.member, inputs.repository, inputs.ref) || '' }} + + - run: uv sync + - uses: pymmcore-plus/setup-mm-test-adapters@main + + - name: Build + env: + # use the OS's native Qt platform, so widget screenshots get its style + QT_QPA_PLATFORM: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'macOS' && 'cocoa' || 'offscreen' }} + SCOPE: ${{ inputs.member || 'hub' }} + run: | + echo "T1=$SECONDS" >> "$GITHUB_ENV" + uv run tools/compose.py build --strict --scope "$SCOPE" + echo "T2=$SECONDS" >> "$GITHUB_ENV" + + - name: Report timing + if: always() + run: | + setup=$(( ${T1:-0} - ${T0:-0} )) + build=$(( ${T2:-$SECONDS} - ${T1:-0} )) + { + echo "### Docs build on ${{ runner.os }} (${{ inputs.os || 'macos-latest' }})" + echo "| step | seconds |" + echo "| --- | --- |" + echo "| setup (checkout, uv sync, adapters) | $setup |" + echo "| generate + build | $build |" + echo "| total | $(( setup + build )) |" + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: site-${{ runner.os }} + path: site + retention-days: 7 + + - uses: actions/upload-artifact@v4 + with: + name: widget-screenshots-${{ runner.os }} + path: .repos/pymmcore-widgets/docs/images/generated + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0a3740e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +# Build and deploy https://pymmcore-plus.github.io from the main branch of every member. +# +# Triggered by pushes here, by members after they merge to main +# (`gh workflow run deploy.yml -R pymmcore-plus/pymmcore-plus.github.io`), and nightly. +name: deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + source: + description: "What triggered this deploy (e.g. owner/repo@sha)" + required: false + schedule: + - cron: "17 5 * * *" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + - run: uv run --no-project tools/repos.py + - run: uv sync + - uses: pymmcore-plus/setup-mm-test-adapters@main + + - name: Build + shell: bash # -o pipefail, so a failed build isn't hidden by `tee` + # not --strict: a warning in one member must not block deploying the others + run: | + echo "Triggered by: ${{ inputs.source || github.event_name }}" + uv run tools/compose.py build 2>&1 | tee build.log + { echo '### Build warnings'; echo '```'; grep -iE 'warning|PROBLEM' build.log || echo none; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-pages-artifact@v4 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..cdc1ae0 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,54 @@ +# Build this repo's pull requests, and publish each as a browsable preview at +# https://pymmcore-plus.github.io/pr-preview/pr-/ (via the gh-pages +# branch). A comment on the PR links to it; it is removed when the PR closes. +# +# The site is built on both macOS and Windows (to compare build times and +# widget screenshots); the macOS build is the one published. +name: preview + +on: + pull_request: + types: [opened, reopened, synchronize, closed] + +concurrency: + group: preview-${{ github.event.number }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + build: + if: github.event.action != 'closed' + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + uses: ./.github/workflows/build.yml + with: + os: ${{ matrix.os }} + + deploy: + needs: build + if: ${{ !cancelled() && github.event.action != 'closed' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: site-macOS + path: site + - uses: rossjrw/pr-preview-action@v1 + with: + source-dir: site + qr-code: false + + remove: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: rossjrw/pr-preview-action@v1 + with: + action: remove diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36aa6dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.repos/ +build/ +site/ +.cache/ +.venv/ +__pycache__/ +zensical.toml +compose.local.toml +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4bc86fa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.2 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bb1c39 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# pymmcore-plus.github.io + +Source for ****: the unified documentation for +the pymmcore-plus ecosystem. + +The site combines docs from several repositories into one site (one navigation, +one search, cross-references everywhere), built with +[zensical](https://zensical.org): + +| Member | Mounted at | +| --- | --- | +| [pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus) | `/pymmcore-plus/` | +| [useq-schema](https://github.com/pymmcore-plus/useq-schema) | `/useq-schema/` | +| [pymmcore-widgets](https://github.com/pymmcore-plus/pymmcore-widgets) | `/pymmcore-widgets/` | +| [pymmcore-gui](https://github.com/pymmcore-plus/pymmcore-gui) | `/pymmcore-gui/` | +| [napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager) | `/napari-micromanager/` | + +Each member keeps its docs in its own `docs/` folder (so PRs can change code and +docs together). This repository holds the theme, the landing page, *Getting +Started*, the site-wide navigation, and the tooling that composes everything. + +## Quick start + +Requires [uv](https://docs.astral.sh/uv/) and [just](https://just.systems). + +```sh +just serve # clone members into .repos/, assemble, serve on localhost:8000 +just build # build into site/ +just check # strict build: fails on any warning +``` + +`uv run tools/compose.py serve --only pymmcore-plus,useq-schema --no-gen` gives +a faster preview of just some members, skipping their generation scripts. + +To work on a member's docs (or code) with live reload, make `.repos/` a +git worktree of your own clone: + +```sh +git -C ~/dev/pymmcore-plus worktree add "$PWD/.repos/pymmcore-plus" -b my-docs-branch +``` + +## How it works + +```text +compose.toml members (name, GitHub repo, branch) +zensical.base.toml theme, markdown extensions, plugins, global mkdocstrings options +redirects.toml old URL -> new URL, for pages that moved +docs/ hub pages, docs/.nav.yml (site navigation), assets/ +overrides/ theme template overrides (edit links, package badge, banner) +tools/repos.py clone / update / worktree member repos into .repos/ +tools/compose.py generate -> copy -> API pages -> zensical.toml -> build +tools/api.py de-duplicated API reference generation +tools/pymm_mdx.py Markdown extension (re-export anchors, small HTML tweaks) +tools/check_urls.py verify every URL of the old sites still resolves +``` + +`tools/compose.py`: + +1. runs each member's optional `docs/_gen.py` (generated content, screenshots); +2. copies the hub's `docs/` and each member's `docs/` into `build/docs/`, + expanding `--8<--` snippets relative to each member's repository and adding + `edit_url` / `package` front matter; +3. generates API reference pages from each member's `api-autonav` settings, + documenting every object once, at its highest-level public name; +4. merges member `zensical.toml` contributions (mkdocstrings `paths`, griffe + `extensions`, inventories) into `zensical.base.toml` -> `zensical.toml`; +5. builds with zensical, then writes per-member `objects.inv` files. + +See [How these docs work](https://pymmcore-plus.github.io/contributing/docs/) +for the member-facing contract. When zensical gains native support for +composing projects, steps 2-4 are what it should replace. + +## Deployment + +- `.github/workflows/deploy.yml` builds from every member's `main` and deploys + to GitHub Pages: on push here, when a member merges to `main` (members call + `gh workflow run deploy.yml`), and nightly. +- `.github/workflows/preview.yml` builds pull requests to this repo (on macOS + and Windows, reporting build times) and publishes the macOS build at + `https://pymmcore-plus.github.io/pr-preview/pr-/`, via the `gh-pages` + branch. +- `.github/workflows/build.yml` is the reusable build, also called by member + PRs to build the whole site with the PR's changes (uploaded as an artifact). +- `compose.overrides.toml` (temporary) points CI at members' unmerged + `zensical-docs` branches; delete it once those are merged. + +Members need an org-level GitHub App (`DOCS_APP_ID` variable + +`DOCS_APP_PRIVATE_KEY` secret) with `actions: write` on this repository to +trigger deploys. diff --git a/compose.overrides.toml b/compose.overrides.toml new file mode 100644 index 0000000..f14221d --- /dev/null +++ b/compose.overrides.toml @@ -0,0 +1,9 @@ +# TEMPORARY: until the member repos merge their docs changes, CI builds them from +# these branches (fresh clones only; local checkouts in .repos/ are not touched). +# Delete this file before merging. +[overrides] +pymmcore-plus = "tlambert03/pymmcore-plus@zensical-docs" +useq-schema = "tlambert03/useq-schema@zensical-docs" +pymmcore-widgets = "tlambert03/pymmcore-widgets@zensical-docs" +pymmcore-gui = "tlambert03/pymmcore-gui@zensical-docs" +napari-micromanager = "tlambert03/napari-micromanager@zensical-docs" diff --git a/compose.toml b/compose.toml new file mode 100644 index 0000000..92c1125 --- /dev/null +++ b/compose.toml @@ -0,0 +1,36 @@ +# Members of the unified docs site. +# +# Each member is checked out at .repos/ (see tools/repos.py) and its +# docs/ folder is mounted at // in the built site. Members own their +# pages, their .nav.yml files, and a minimal zensical.toml (mkdocstrings paths, +# griffe extensions, api-autonav settings). + +[[member]] +name = "pymmcore-plus" +repo = "pymmcore-plus/pymmcore-plus" +title = "pymmcore-plus" +pypi = "pymmcore-plus" + +[[member]] +name = "useq-schema" +repo = "pymmcore-plus/useq-schema" +title = "useq-schema" +pypi = "useq-schema" + +[[member]] +name = "pymmcore-widgets" +repo = "pymmcore-plus/pymmcore-widgets" +title = "pymmcore-widgets" +pypi = "pymmcore-widgets" + +[[member]] +name = "pymmcore-gui" +repo = "pymmcore-plus/pymmcore-gui" +title = "pymmcore-gui" +pypi = "pymmcore-gui" + +[[member]] +name = "napari-micromanager" +repo = "pymmcore-plus/napari-micromanager" +title = "napari-micromanager" +pypi = "napari-micromanager" diff --git a/docs/.nav.yml b/docs/.nav.yml new file mode 100644 index 0000000..4c05810 --- /dev/null +++ b/docs/.nav.yml @@ -0,0 +1,58 @@ +# Site-wide navigation (awesome-nav). Member pages are placed here by path; +# each member's own docs/.nav.yml orders its API Reference section. +nav: + - Home: index.md + - Getting Started: + - getting-started/index.md + - getting-started/install.md + - Micro-Manager device adapters: pymmcore-plus/install.md + - getting-started/first-steps.md + - getting-started/first-acquisition.md + - getting-started/first-gui.md + - User Guide: + - guide/index.md + - Controlling hardware: + - pymmcore-plus/guides/devices.md + - pymmcore-plus/guides/configuration.md + - pymmcore-plus/guides/constants.md + - pymmcore-plus/guides/events.md + - pymmcore-plus/examples/following_changes_in_core.md + - pymmcore-plus/examples/context-set.md + - Describing experiments: + - useq-schema/schema/sequence.md + - useq-schema/schema/event.md + - useq-schema/schema/axes.md + - useq-schema/schema/hardware_autofocus.md + - useq-schema/v2-migration.md + - Running acquisitions: + - pymmcore-plus/guides/mda_engine.md + - pymmcore-plus/examples/mda.md + - pymmcore-plus/guides/event_driven_acquisition.md + - pymmcore-plus/guides/custom_engine.md + - Building GUIs: + - pymmcore-widgets/getting_started.md + - Widget gallery: pymmcore-widgets/widgets/index.md + - pymmcore-plus/examples/integration-with-qt.md + - Applications: + - pymmcore-gui + - napari-micromanager + - Advanced topics: + - pymmcore-plus/guides/unicore.md + - pymmcore-plus/guides/logging.md + - pymmcore-plus/profiling.md + - pymmcore-plus/guides/ipython_completion.md + - pymmcore-plus/examples/pycro-manager-api.md + - Troubleshooting: + - pymmcore-plus/troubleshooting.md + - pymmcore-widgets/troubleshooting.md + - API Reference: + - api/index.md + - pymmcore-plus + - useq-schema + - pymmcore-widgets + - pymmcore-gui: pymmcore-gui/api/pymmcore_gui + - Contributing: + - contributing/index.md + - contributing/docs.md + - pymmcore-plus/contributing.md + - pymmcore-widgets/contributing.md diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..1618a8b --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,50 @@ +--- +title: API Reference +--- + +# API Reference + +The API reference is generated from the source code of each package. Every +public object is documented once, under its **shortest public import path** +(e.g. `pymmcore_plus.CMMCorePlus`, not `pymmcore_plus.core.CMMCorePlus`); +other modules that re-export it link there. + +
+ +- **[pymmcore-plus](../pymmcore-plus/index.md)** + + --- + + [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] (every core method, with what + pymmcore-plus adds), the [acquisition engine][pymmcore_plus.mda], + device objects, constants, metadata, and the + [`mmcore` CLI](../pymmcore-plus/cli.md). + +- **[useq-schema](../useq-schema/index.md)** + + --- + + [`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent], and all + axis plans. + +- **[pymmcore-widgets](../pymmcore-widgets/index.md)** + + --- + + Every widget, with a screenshot and an example. + +- **[pymmcore-gui](../pymmcore-gui/api/pymmcore_gui/index.md)** + + --- + + Application entry points and extension points (actions, widgets). + +
+ +!!! tip "Linking to these docs from your own project" + + Each package publishes an object inventory for + [mkdocstrings](https://mkdocstrings.github.io/) and + [intersphinx](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html), + e.g. `https://pymmcore-plus.github.io/pymmcore-plus/objects.inv` (or + `/objects.inv` for everything). diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css new file mode 100644 index 0000000..f5c11d4 --- /dev/null +++ b/docs/assets/css/pymm.css @@ -0,0 +1,388 @@ +/* Styles for the unified pymmcore-plus docs. */ + +/* --------------------------------------------------------------------------- + Brand colors (palette primary/accent = "custom") + ------------------------------------------------------------------------ */ + +:root, +[data-md-color-scheme="default"] { + --md-primary-fg-color: #0b6e8e; + --md-primary-fg-color--light: #2a90b2; + --md-primary-fg-color--dark: #08506a; + --md-primary-bg-color: #ffffff; + --md-accent-fg-color: #0d9488; + --md-accent-fg-color--transparent: #0d94881a; + --pymm-added: #b45309; + --pymm-overridden: #0b6e8e; + --pymm-inherited: #6b7280; + --pymm-surface: #f6f8fa; + --pymm-border: #e1e4e8; +} + +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #38b2d6; + --md-primary-fg-color--light: #6ccbe8; + --md-primary-fg-color--dark: #1a8fb4; + --md-primary-bg-color: #0f1419; + --md-accent-fg-color: #2dd4bf; + --md-accent-fg-color--transparent: #2dd4bf1a; + --pymm-added: #f59e0b; + --pymm-overridden: #38b2d6; + --pymm-inherited: #9ca3af; + --pymm-surface: #161b22; + --pymm-border: #30363d; +} + +/* --------------------------------------------------------------------------- + Landing page + ------------------------------------------------------------------------ */ + +.pymm-hero { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + gap: 2.5rem; + align-items: center; + margin: 1rem 0 2.5rem; +} + +.pymm-hero h1 { + font-size: 2.4rem; + font-weight: 700; + line-height: 1.15; + margin-bottom: 0.8rem; + letter-spacing: -0.02em; +} + +.pymm-hero__text p { + font-size: 0.95rem; +} + +.pymm-hero__code .highlight { + box-shadow: 0 10px 30px -12px rgb(0 0 0 / 35%); + border-radius: 0.6rem; + overflow: hidden; +} + +@media screen and (max-width: 60em) { + .pymm-hero { + grid-template-columns: 1fr; + } +} + +/* Layered "how the pieces fit together" diagram */ +.pymm-stack { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 1.2rem 0 2rem; +} + +.pymm-layer { + position: relative; + display: grid; + grid-template-columns: 11rem 1fr; + grid-template-areas: "label pkgs" "label desc"; + column-gap: 1rem; + align-items: center; + padding: 0.7rem 1.1rem; + border-radius: 0.5rem; + border: 1px solid var(--pymm-border); + background: var(--pymm-surface); + border-left: 0.35rem solid var(--pymm-layer-color, var(--md-primary-fg-color)); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.pymm-layer__pkgs { + grid-area: pkgs; +} + +.pymm-layer__pkgs p { + margin: 0; +} + +.pymm-layer:hover { + transform: translateX(4px); + box-shadow: 0 6px 18px -10px rgb(0 0 0 / 40%); +} + +.pymm-layer__label { + grid-area: label; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pymm-layer-color, var(--md-primary-fg-color)); +} + +.pymm-layer__desc { + grid-area: desc; + display: block; + font-size: 0.7rem; + color: var(--md-default-fg-color--light); +} + +.pymm-layer--apps { --pymm-layer-color: #7c3aed; } +.pymm-layer--widgets { --pymm-layer-color: #db2777; } +.pymm-layer--core { --pymm-layer-color: var(--md-primary-fg-color); } +.pymm-layer--mm { --pymm-layer-color: #0d9488; } +.pymm-layer--hw { --pymm-layer-color: #6b7280; } + +@media screen and (max-width: 45em) { + .pymm-layer { + grid-template-columns: 1fr; + grid-template-areas: "label" "pkgs" "desc"; + } +} + +/* --------------------------------------------------------------------------- + Package badge (member pages) + ------------------------------------------------------------------------ */ + +.pymm-package-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin: 0 0 0.6rem; + padding: 0.15rem 0.6rem 0.15rem 0.7rem; + border: 1px solid var(--pymm-border); + border-radius: 1rem; + font-size: 0.68rem; + line-height: 1.6; +} + +.pymm-package-badge a { + color: var(--md-default-fg-color--light); +} + +.pymm-package-badge__name { + font-weight: 600; +} + +.pymm-package-badge img { + vertical-align: middle; + height: 1rem; +} + +.pymm-package-badge svg { + width: 0.9rem; + height: 0.9rem; + vertical-align: middle; + fill: currentcolor; +} + +/* --------------------------------------------------------------------------- + CMMCorePlus method explorer + ------------------------------------------------------------------------ */ + +.pymm-legend { + font-size: 0.7rem; + color: var(--md-default-fg-color--light); +} + +.md-typeset table.pymm-explorer { + font-size: 0.68rem; + display: table; + width: 100%; +} + +.md-typeset table.pymm-explorer td { + padding: 0.35em 0.8em; + vertical-align: top; +} + +.md-typeset table.pymm-explorer td:first-child, +.md-typeset table.pymm-explorer td:nth-child(3) { + white-space: nowrap; +} + +.md-typeset table.pymm-explorer td:nth-child(3) a { + color: var(--md-default-fg-color--light); +} + +.md-typeset table.pymm-explorer tr.pymm-hidden { + display: none; +} + +.md-typeset table.pymm-explorer tr[data-deprecated="true"] td { + opacity: 0.55; +} + +.md-typeset table.pymm-explorer tr[data-deprecated="true"] td:first-child code { + text-decoration: line-through; +} + +.pymm-sig { + display: block; + font-family: var(--md-code-font-family); + font-size: 0.85em; + color: var(--md-default-fg-color--lighter); + white-space: normal; + max-width: 22rem; +} + +.pymm-origin { + font-size: 1rem; + cursor: help; +} + +.pymm-origin--overridden { + color: var(--pymm-overridden); +} + +/* Toolbar built by pymm.js */ +.pymm-explorer-toolbar { + position: sticky; + top: 2.4rem; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + padding: 0.6rem 0; + background: var(--md-default-bg-color); + font-size: 0.68rem; +} + +.pymm-explorer-toolbar input[type="search"], +.pymm-explorer-toolbar select { + font: inherit; + padding: 0.3rem 0.55rem; + border: 1px solid var(--pymm-border); + border-radius: 0.35rem; + background: var(--md-default-bg-color); + color: var(--md-default-fg-color); +} + +.pymm-explorer-toolbar input[type="search"] { + flex: 1 1 12rem; + min-width: 10rem; +} + +.pymm-chip { + font: inherit; + padding: 0.25rem 0.65rem; + border: 1px solid var(--pymm-border); + border-radius: 1rem; + background: transparent; + color: var(--md-default-fg-color--light); + cursor: pointer; +} + +.pymm-chip[aria-pressed="true"] { + background: var(--md-primary-fg-color); + border-color: var(--md-primary-fg-color); + color: var(--md-primary-bg-color); +} + +.pymm-chip__count { + opacity: 0.7; + margin-left: 0.25em; +} + +.pymm-explorer-count { + margin-left: auto; + color: var(--md-default-fg-color--lighter); +} + +/* Origin badge added to member headings by pymm.js */ +.pymm-heading-origin { + font-size: 0.55rem; + font-weight: 600; + margin-left: 0.6em; + padding: 0.1em 0.55em; + border-radius: 1em; + vertical-align: middle; + border: 1px solid currentcolor; + font-family: var(--md-text-font-family); +} + +.pymm-heading-origin--added { + color: var(--pymm-added); +} + +.pymm-heading-origin--overridden { + color: var(--pymm-overridden); +} + +/* "Why Override?" (inserted by tools/pymm_mdx.py) */ +.pymm-why-override { + display: inline-flex; + align-items: center; + gap: 0.2em; + color: var(--pymm-overridden); +} + +.pymm-why-override .twemoji svg { + fill: currentcolor; +} + +/* --------------------------------------------------------------------------- + Install builder + ------------------------------------------------------------------------ */ + +.pymm-installer-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.4rem 1.5rem; + padding: 0.8rem 1rem; + border: 1px solid var(--pymm-border); + border-radius: 0.5rem; + background: var(--pymm-surface); + font-size: 0.72rem; +} + +.pymm-installer-form fieldset { + border: 0; + margin: 0; + padding: 0; +} + +.pymm-installer-form legend { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.pymm-installer-form label { + display: block; + cursor: pointer; +} + +.pymm-installer-form small { + color: var(--md-default-fg-color--light); +} + +/* --------------------------------------------------------------------------- + Widget gallery & screenshots + ------------------------------------------------------------------------ */ + +.md-typeset .widget-image { + max-height: 600px; + display: block; + margin: auto; + border-radius: 0.3rem; + box-shadow: 0 6px 20px -10px rgb(0 0 0 / 45%); +} + +.md-typeset .pymm-gallery > ul > li img, +.md-typeset .pymm-gallery > ol > li img { + display: block; + max-height: 170px; + max-width: 100%; + margin: 0 auto 0.6rem; + object-fit: contain; +} + +/* --------------------------------------------------------------------------- + API docs tweaks + ------------------------------------------------------------------------ */ + +/* indent members under their parent object */ +.md-typeset .doc-contents:not(.first) { + padding-left: 1.2em; + border-left: 0.05rem solid var(--md-typeset-table-color); +} + +/* symbol badges in the navigation (from api-autonav-style titles) */ +.md-nav .doc-symbol-nav { + margin-right: 0.3em; +} diff --git a/docs/assets/img/favicon.ico b/docs/assets/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..537987b939cd2daa0f299e35e85fe9ac144251fd GIT binary patch literal 15406 zcmeHN`BxQn6rWE00ro@ZI=D6+50?fV&=Y2NVWy_vTF9^dzz`)20*zW3hGy>sWg-0zp; zUg2Kl+O^}@)sCC|8pqjl9A{_O^xXY*j*Dk?J$pX?zZ=J;zrk_cSsTW}IGXC&MQ?HX z-p%+0 z9|SrNqwp!NIyA-6o^Kz!Fo>72F?yaNzf13qIa=KesKtKA9(P^9bYb=$PJkqI?^5P!<3*}1IIi0qdX~*c+wLG0V zIe%HnV}YIWFJ*{rm(B8}XV0F|)>VtdIyo<%<@{lOPLz>ynCH#AaE9DJ?V^c4VPymr z6Yr$9OeA>^>!F!nF3+0J zdOHOTmFQ>e7!6~D{z<7=(*~RDw$X=cc@MkREu(9fFIt8E*cos1n>&4Ezxa(?`UYc- zF<;DNnBE%rA z4Vy{Q@fNHGGTPn{7Uy0a=;oCi(VlH<7BhM64a)Q# z(Nol4cq89nTSL0z!b9ls!~68Kp+VGNQ(Z-nIm2m)qFls1zCV#9~aXc&#%nVt;T*g`z};lQ!S|F*A!W2QV9LGvDi}czcK$~%)E2^CY!fh zAgn9#9@NNH+s2rFwQ@{t_pbmh=XF``QJ87Jb+H-jUbf_h{4dFiJVFTci{F zchREho`gAqJ|0WgP5A5sS{gi&u-*q@YZUh}q+)inS6I+#14*tgWZ+tY*gZ6BQX|`sO&hJ|49`ncx zr;cmJ|8wXRey!6|IFv=hb$N&#MhljmR zY01yd!v4uq3M_cEWXN~{C^$*`Bmh|Erm}>4syDwhxSs& ziNkd6#39-lx0JjFchjsF`4sHoBX)!sLatg#uchi!*?x?m6MCzwDrgqV6>F96&hnku z(~`FRYe&^1x>Ju-vwJt!9Sn%qe*N%d-_Nc-7C7Kv+oZZfW5r5 zgx`}fXWb=WG<4O9A9FjLi9~-2occ;_bv12deUO@SlZLRV`OhT$?5qmb<e8oUi(-c@n(;Fpxer?N*@nM$-^YvY|BRGBrQM+7JFegi9XRa`}w1$35$qXGoO79s~ zoL#ln+}BdrAqV-rFh6ICnCdPPa-<>dpSNkhTha$MS*<&k*!*wve`~}5J04^!HV>HW Gf&T!mH2fg| literal 0 HcmV?d00001 diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js new file mode 100644 index 0000000..a89ea06 --- /dev/null +++ b/docs/assets/js/pymm.js @@ -0,0 +1,192 @@ +// Interactive bits for the unified pymmcore-plus docs. +// Runs on every page load, including zensical's instant navigation (document$). + +(() => { + "use strict"; + + const ORIGINS = { + added: { label: "✨ Added", badge: "pymmcore-plus" }, + overridden: { label: "➕ Overridden", badge: "overridden" }, + inherited: { label: "Inherited", badge: null }, + }; + + // ------------------------------------------------------------------------- + // Method explorer: filters for table.pymm-explorer (see pymmcore-plus _gen.py) + // ------------------------------------------------------------------------- + + function initExplorer(table) { + if (table.dataset.pymmReady) return; + table.dataset.pymmReady = "1"; + const rows = [...table.tBodies[0].rows]; + const state = { origin: "all", category: "", text: "", deprecated: false }; + + const toolbar = document.createElement("div"); + toolbar.className = "pymm-explorer-toolbar"; + + const search = Object.assign(document.createElement("input"), { + type: "search", + placeholder: "Filter methods (name or description)…", + ariaLabel: "Filter methods", + }); + search.addEventListener("input", () => { + state.text = search.value.trim().toLowerCase(); + apply(); + }); + toolbar.append(search); + + const chips = []; + const count = (o) => rows.filter((r) => o === "all" || r.dataset.origin === o).length; + for (const [key, label] of [["all", "All"], ...Object.entries(ORIGINS).map(([k, v]) => [k, v.label])]) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "pymm-chip"; + chip.dataset.origin = key; + chip.innerHTML = `${label}${count(key)}`; + chip.addEventListener("click", () => { + state.origin = key; + apply(); + }); + chips.push(chip); + toolbar.append(chip); + } + + const select = document.createElement("select"); + select.ariaLabel = "Category"; + const categories = new Map(); + for (const row of rows) { + categories.set(row.dataset.category, row.cells[2].textContent.trim()); + } + select.append(new Option("All categories", "")); + for (const [value, text] of [...categories].sort((a, b) => a[1].localeCompare(b[1]))) { + select.append(new Option(text, value)); + } + select.addEventListener("change", () => { + state.category = select.value; + apply(); + }); + toolbar.append(select); + + const deprecated = document.createElement("label"); + deprecated.innerHTML = ' show deprecated'; + deprecated.querySelector("input").addEventListener("change", (e) => { + state.deprecated = e.target.checked; + apply(); + }); + toolbar.append(deprecated); + + const status = document.createElement("span"); + status.className = "pymm-explorer-count"; + toolbar.append(status); + + (table.closest(".md-typeset__scrollwrap") || table).before(toolbar); + + function apply() { + let shown = 0; + for (const row of rows) { + const visible = + (state.origin === "all" || row.dataset.origin === state.origin) && + (!state.category || row.dataset.category === state.category) && + (state.deprecated || row.dataset.deprecated !== "true") && + (!state.text || row.textContent.toLowerCase().includes(state.text)); + row.classList.toggle("pymm-hidden", !visible); + shown += visible; + } + for (const chip of chips) { + chip.setAttribute("aria-pressed", String(chip.dataset.origin === state.origin)); + } + status.textContent = `${shown} of ${rows.length}`; + } + apply(); + decorateHeadings(table, rows); + } + + // Add an origin badge to each member heading documented on the page. + function decorateHeadings(table, rows) { + const prefix = table.dataset.object; + for (const row of rows) { + const origin = ORIGINS[row.dataset.origin]; + if (!origin.badge) continue; + const name = row.querySelector("code").textContent; + const heading = document.getElementById(`${prefix}.${name}`); + if (!heading || heading.querySelector(".pymm-heading-origin")) continue; + const badge = document.createElement("span"); + badge.className = `pymm-heading-origin pymm-heading-origin--${row.dataset.origin}`; + badge.textContent = origin.badge; + badge.title = row.querySelector(".pymm-origin")?.title || ""; + const anchor = heading.querySelector(".headerlink"); + anchor ? anchor.before(badge) : heading.append(badge); + } + } + + // ------------------------------------------------------------------------- + // Install builder: rewrites the pip/uv commands in [data-pymm-installer] + // ------------------------------------------------------------------------- + + const INSTALL_OPTIONS = [ + { id: "io", label: "Save to OME-Zarr / OME-TIFF", hint: "pymmcore-plus[io]", group: "core" }, + { id: "widgets", label: "Qt widgets", hint: "pymmcore-widgets", group: "gui" }, + { id: "gui", label: "pymmcore-gui app", hint: "from GitHub", group: "gui" }, + { id: "napari", label: "napari plugin", hint: "napari-micromanager", group: "gui" }, + ]; + + function packages(opts) { + const qt = opts.qt; + const pkgs = [`"pymmcore-plus[${opts.io ? "cli,io" : "cli"}]"`]; + if (opts.widgets) pkgs.push(`"pymmcore-widgets[${qt}]"`); + if (opts.gui) pkgs.push("git+https://github.com/pymmcore-plus/pymmcore-gui"); + if (opts.napari) pkgs.push(`"napari-micromanager[${qt.toLowerCase()}]"`); + return pkgs; + } + + function initInstaller(root) { + if (root.dataset.pymmReady) return; + root.dataset.pymmReady = "1"; + const form = document.createElement("form"); + form.className = "pymm-installer-form"; + form.innerHTML = ` +
pymmcore-plus + + ${INSTALL_OPTIONS.filter((o) => o.group === "core").map(checkbox).join("")} +
+
User interfaces + ${INSTALL_OPTIONS.filter((o) => o.group === "gui").map(checkbox).join("")} +
+
Qt backend + + +
`; + root.prepend(form); + + const [pip, uv] = root.querySelectorAll(".tabbed-block pre code"); + const blocks = { pip, uv }; + function update() { + const data = new FormData(form); + const opts = { qt: data.get("qt") }; + for (const o of INSTALL_OPTIONS) opts[o.id] = data.has(o.id); + const anyQt = opts.widgets || opts.gui || opts.napari; + form.querySelectorAll('input[name="qt"]').forEach((el) => (el.disabled = !anyQt)); + const pkgs = packages(opts).join(" "); + if (blocks.pip) blocks.pip.textContent = `pip install ${pkgs}\nmmcore install # Micro-Manager device adapters`; + if (blocks.uv) blocks.uv.textContent = `uv add ${pkgs}\nuv run mmcore install # Micro-Manager device adapters`; + } + form.addEventListener("change", update); + update(); + } + + function checkbox(o) { + return ``; + } + + // ------------------------------------------------------------------------- + + function init() { + document.querySelectorAll("table.pymm-explorer").forEach(initExplorer); + document.querySelectorAll("[data-pymm-installer]").forEach(initInstaller); + } + + if (typeof document$ !== "undefined") { + document$.subscribe(init); + } else { + document.addEventListener("DOMContentLoaded", init); + } +})(); diff --git a/docs/contributing/docs.md b/docs/contributing/docs.md new file mode 100644 index 0000000..fe18be1 --- /dev/null +++ b/docs/contributing/docs.md @@ -0,0 +1,98 @@ +--- +title: How these docs work +--- + +# How these docs work + +This site combines documentation from several repositories into a single site, +with one navigation, one search, and cross-references between all packages. It +is built with [zensical](https://zensical.org) by +[pymmcore-plus/pymmcore-plus.github.io](https://github.com/pymmcore-plus/pymmcore-plus.github.io) +(the *hub*). + +```mermaid +graph LR + subgraph members [Member repositories] + plus[pymmcore-plus
docs/ + src/] + useq[useq-schema
docs/ + src/] + widgets[pymmcore-widgets
docs/ + src/] + more[...] + end + hub[hub: compose.py] --> build[build/docs + zensical.toml] --> site[pymmcore-plus.github.io] + plus --> hub + useq --> hub + widgets --> hub + more --> hub +``` + +## Who owns what + +**Each member repository** owns: + +- `docs/`: its pages, written exactly as for a stand-alone site. The folder is + published at `//`, so `pymmcore-plus/docs/guides/events.md` becomes + `/pymmcore-plus/guides/events/`. +- `docs/**/.nav.yml`: [awesome-nav](https://lukasgeiter.github.io/mkdocs-awesome-nav/) + files ordering the package's own section. +- `zensical.toml`: what is specific to the package: mkdocstrings `paths`, + griffe `extensions`, and the `api-autonav` settings for its API reference. +- `docs/_gen.py` (optional): a script that generates content before the build, + writing into git-ignored files (for example the + [`CMMCorePlus` method table][pymmcore_plus.CMMCorePlus] or the widget + screenshots). + +**The hub** owns the theme, global settings, the landing page, *Getting +Started*, and the top-level navigation (`docs/.nav.yml`), which places member +pages into the site-wide *User Guide*. + +## Previewing locally + +```sh +git clone https://github.com/pymmcore-plus/pymmcore-plus.github.io +cd pymmcore-plus.github.io +just serve # clones members into .repos/ and serves http://localhost:8000 +``` + +To edit a member's docs or code and see the result live, put a git worktree of +your own clone in `.repos/` instead (`tools/repos.py --worktrees ~/path/to/clones`, +or `git worktree add`). Use `--only pymmcore-plus,useq-schema` for a faster +preview of just some packages, and `--no-gen` to skip the generation scripts. + +`just check` builds with `--strict`, which fails on any warning (broken links, +unresolved cross-references, docstring problems). + +## The API reference + +API pages are generated from each member's `api-autonav` settings, with two +rules on top: + +1. **Each object is documented once, at its highest-level public name.** + `pymmcore_plus.Device` is documented on the `pymmcore_plus` page; the + `pymmcore_plus.core` page lists it under *Re-exports* with a link. + Cross-references to any public path (`[x][pymmcore_plus.core.Device]`) still + resolve. +2. **Hand-written pages claim objects.** If a page in `docs/` renders an object + with a `::: identifier` block, that page is where the object is documented, + and the generated pages link to it. A hand-written page at a module's + generated path (for example `docs/api/pymmcore_plus/metadata/index.md`) + replaces the generated page. + +## Writing tips + +- Link to API objects with [autorefs](https://mkdocstrings.github.io/autorefs/): + `` [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] `` works from any page in any + repository. +- Link to pages in other packages with relative paths + (`../useq-schema/schema/axes.md`); they are checked when the site is built. +- Snippets (`--8<-- "examples/foo.py"`) are resolved relative to the member's + repository root. +- If you want a heading to be linked from other repositories, give it an + explicit, namespaced id: `## Installing {#widgets-install}`. + +## How changes get published + +- **Pull requests** to a member repository build the whole site with the PR's + changes (failing only on warnings in that member's pages), and upload the + result as an artifact you can download and browse. +- **Merges** to `main` trigger a rebuild and deploy of the full site. The site + is also rebuilt nightly. diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 0000000..7a4c5f5 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,49 @@ +--- +title: Contributing +--- + +# Contributing + +Thanks for thinking of a way to help! Contributions come in many shapes: +reporting bugs, asking questions where something is unclear, improving +documentation, and writing code are all valuable. + +## Where things live + +Each package has its own repository, issue tracker, and contributing notes: + +| Package | Repository | Contributing notes | +| --- | --- | --- | +| pymmcore-plus | [pymmcore-plus/pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus) | [Contributing to pymmcore-plus](../pymmcore-plus/contributing.md) | +| useq-schema | [pymmcore-plus/useq-schema](https://github.com/pymmcore-plus/useq-schema) | | +| pymmcore-widgets | [pymmcore-plus/pymmcore-widgets](https://github.com/pymmcore-plus/pymmcore-widgets) | [Contributing to pymmcore-widgets](../pymmcore-widgets/contributing.md) | +| pymmcore-gui | [pymmcore-plus/pymmcore-gui](https://github.com/pymmcore-plus/pymmcore-gui) | [CONTRIBUTING.md](https://github.com/pymmcore-plus/pymmcore-gui/blob/main/CONTRIBUTING.md) | +| napari-micromanager | [pymmcore-plus/napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager) | | +| this website | [pymmcore-plus/pymmcore-plus.github.io](https://github.com/pymmcore-plus/pymmcore-plus.github.io) | [How these docs work](docs.md) | + +Not sure where something belongs? Ask on the +[image.sc forum](https://forum.image.sc/tag/pymmcore-plus) or +[Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D). + +## Development setup + +All repositories use [uv](https://docs.astral.sh/uv/) and +[prek](https://github.com/j178/prek) (or pre-commit): + +```sh +git clone https://github.com/pymmcore-plus/ +cd +uv sync +prek install +uv run pytest +``` + +Most tests need the Micro-Manager demo device adapters: `uv run mmcore install`. + +## Improving the docs + +Every page has an :material-file-edit-outline: **edit** button (top right) that +opens the source file in the right repository. Each package's docs live in its +own `docs/` folder (and in its docstrings), so a pull request that changes code +can update the documentation at the same time. To preview the full site +locally, see [How these docs work](docs.md). diff --git a/docs/getting-started/first-acquisition.md b/docs/getting-started/first-acquisition.md new file mode 100644 index 0000000..c6e0027 --- /dev/null +++ b/docs/getting-started/first-acquisition.md @@ -0,0 +1,99 @@ +--- +title: Your first acquisition +--- + +# Your first acquisition + +A *multi-dimensional acquisition* (MDA) is a series of images collected across +time points, stage positions, channels, z-planes, and so on. It takes two +pieces: + +- [**useq-schema**](../useq-schema/index.md) to **describe** the experiment, and +- the [**pymmcore-plus** acquisition engine](../pymmcore-plus/guides/mda_engine.md) + to **run** it. + +## Describe the experiment + +An [`MDASequence`][useq.MDASequence] declares what to acquire. It is plain data: +you can save it as JSON or YAML, and it knows nothing about hardware. + +```python +import useq + +sequence = useq.MDASequence( + channels=["DAPI", {"config": "FITC", "exposure": 50}], # (1)! + time_plan={"interval": 0.5, "loops": 3}, # (2)! + z_plan={"range": 4, "step": 1}, # (3)! + stage_positions=[(0, 0, 0), (100, 100, 0)], # (4)! + axis_order="tpcz", # (5)! +) + +sequence.sizes # {'t': 3, 'p': 2, 'c': 2, 'z': 5} +len(list(sequence)) # 60 events +``` + +1. Channels are configuration presets in the `Channel` group (the default + group), with an optional exposure. +2. 3 time points, 0.5 s apart. +3. A 4 µm z-stack around the current position, in 1 µm steps. +4. Two `(x, y, z)` stage positions. +5. The nesting order: for each **t**ime point, visit each **p**osition, image each + **c**hannel, then each **z** plane. + +Iterating the sequence yields [`MDAEvent`][useq.MDAEvent] objects — one per +image — each saying exactly what the hardware should do. See +[Describing experiments](../useq-schema/schema/sequence.md) for everything a +sequence can express (grids, well plates, autofocus, per-position +sub-sequences, ...). + +## Run it + +```python +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() + +@core.mda.events.frameReady.connect # (1)! +def on_frame(image, event, metadata): + print(event.index, image.shape) + +core.mda.run(sequence, output="experiment.ome.zarr") # (2)! +``` + +1. Connect to [events][pymmcore_plus.mda.events.PMDASignaler] to process data + as it arrives. +2. `mda.run` blocks until the acquisition is done. Use + [`core.run_mda(sequence)`][pymmcore_plus.CMMCorePlus.run_mda] to run on a + background thread instead. + +The `output` argument saves to OME-Zarr (or `.ome.tiff`) using +[ome-writers](https://pymmcore-plus.github.io/ome-writers/); install it with +`pip install "pymmcore-plus[io]"`. + +## Beyond sequences + +The engine accepts **any iterable of `MDAEvent`s**, not just an +`MDASequence`. That makes it easy to run adaptive, "smart" microscopy where +each event is decided based on previous images: + +```python +from queue import Queue + +events = Queue() +STOP = object() +core.run_mda(iter(events.get, STOP)) # runs until STOP is put in the queue + +events.put(useq.MDAEvent(channel="DAPI", exposure=10)) +events.put(useq.MDAEvent(x_pos=100, y_pos=200, channel="FITC")) +events.put(STOP) +``` + +## Where next? + +- [The acquisition engine](../pymmcore-plus/guides/mda_engine.md): everything about + running MDAs: pausing, cancelling, hardware triggering, serialization. +- [Event-driven acquisition](../pymmcore-plus/guides/event_driven_acquisition.md) + and [custom acquisition engines](../pymmcore-plus/guides/custom_engine.md). +- [Your first GUI](first-gui.md): run the same experiments from a graphical + interface. diff --git a/docs/getting-started/first-gui.md b/docs/getting-started/first-gui.md new file mode 100644 index 0000000..e466162 --- /dev/null +++ b/docs/getting-started/first-gui.md @@ -0,0 +1,97 @@ +--- +title: Your first GUI +--- + +# Your first GUI + +There are two ways to get a graphical interface: + +- **Use an application**: [pymmcore-gui](#use-an-application) or + [napari-micromanager](#use-an-application). +- **Build your own** from [pymmcore-widgets](../pymmcore-widgets/widgets/index.md), + a library of Qt widgets that control a `CMMCorePlus`. + +## Use an application + +=== "pymmcore-gui" + + ```sh + pip install git+https://github.com/pymmcore-plus/pymmcore-gui + mmgui + ``` + + Or download a [bundled app](../pymmcore-gui/install.md). See + [Using pymmcore-gui](../pymmcore-gui/usage.md). + +=== "napari-micromanager" + + ```sh + pip install "napari-micromanager[pyqt6]" + napari-micromanager + ``` + + See [getting started with napari-micromanager](../napari-micromanager/getting_started.md). + +## Build your own + +Every widget in `pymmcore-widgets` connects to a `CMMCorePlus` instance (by +default, the global [`CMMCorePlus.instance()`][pymmcore_plus.CMMCorePlus.instance]), +listens to its [events](../pymmcore-plus/guides/events.md), and stays in sync with +the hardware — whether the change came from the widget, from another +widget, or from your own code. + +Install the widgets with a Qt backend: + +```sh +pip install "pymmcore-widgets[PyQt6]" +``` + +Then combine widgets like any other Qt widgets: + +```python +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget + +from pymmcore_widgets import ( + ChannelWidget, + ExposureWidget, + ImagePreview, + LiveButton, + SnapButton, +) + +app = QApplication([]) + +core = CMMCorePlus.instance() # (1)! +core.loadSystemConfiguration() + +window = QWidget() +controls = QHBoxLayout() +controls.addWidget(ChannelWidget()) +controls.addWidget(ExposureWidget()) +controls.addWidget(SnapButton()) +controls.addWidget(LiveButton()) + +layout = QVBoxLayout(window) +layout.addLayout(controls) +layout.addWidget(ImagePreview()) +window.show() + +app.exec() +``` + +1. Widgets use the global core by default. Pass `mmcore=...` to a widget to use + a specific instance. + +A slightly larger version of this example looks like this: + +![type:video](../pymmcore-widgets/images/my_widget.mp4){: style='width: 100%'} + +## Where next? + +- The [widget gallery](../pymmcore-widgets/widgets/index.md) shows every widget, + each with a screenshot and a runnable example. +- [Getting started with pymmcore-widgets](../pymmcore-widgets/getting_started.md) + walks through a complete custom GUI. +- [Qt integration](../pymmcore-plus/examples/integration-with-qt.md) explains how + to keep the GUI responsive while the hardware works. diff --git a/docs/getting-started/first-steps.md b/docs/getting-started/first-steps.md new file mode 100644 index 0000000..e165255 --- /dev/null +++ b/docs/getting-started/first-steps.md @@ -0,0 +1,103 @@ +--- +title: First steps +--- + +# First steps: scripting the core + +Everything starts with [`CMMCorePlus`][pymmcore_plus.CMMCorePlus], a drop-in +replacement for Micro-Manager's `CMMCore` with a more Pythonic API. If you have +used Micro-Manager's Java API or `pymmcore`, everything you know still applies. + +!!! info "Before you start" + + [Install](install.md) `pymmcore-plus` and the device adapters. These examples + use Micro-Manager's **demo configuration**, so no hardware is needed. + +## Load a configuration + +```python +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() # (1)! + +print(core.getLoadedDevices()) +# ('DHub', 'Camera', 'Dichroic', 'Emission', 'Excitation', 'Objective', 'Z', ...) +``` + +1. With no argument, this loads the demo configuration (`MMConfig_demo.cfg`). + Pass the path to your own `.cfg` file to use your hardware. + +## Snap an image + +```python +core.setExposure(25) +image = core.snap() # (1)! +print(image.shape, image.dtype) # (512, 512) uint16 +``` + +1. [`snap`][pymmcore_plus.CMMCorePlus.snap] is a `CMMCorePlus` convenience that + calls `snapImage()` and returns `getImage()` as a numpy array. + +## Read and set properties + +Every device exposes **properties**. You can use the classic string-based API: + +```python +core.getProperty("Camera", "Binning") # '1' +core.setProperty("Camera", "Binning", "2") +core.getImageWidth() # 256 +``` + +...or the object-oriented [`Device`][pymmcore_plus.Device] and +[`DeviceProperty`][pymmcore_plus.DeviceProperty] views: + +```python +camera = core.getDeviceObject("Camera") +gain = camera.getPropertyObject("Gain") +gain.value = 1 +gain.range() # (-5.0, 8.0) +``` + +See [Devices & properties](../pymmcore-plus/guides/devices.md) for more. + +## Use configuration groups + +Configuration groups (or *presets*) bundle many property settings under one +name, such as an imaging channel: + +```python +core.getAvailableConfigs("Channel") # ('Cy5', 'DAPI', 'FITC', 'Rhodamine') +core.setConfig("Channel", "FITC") +core.getCurrentConfig("Channel") # 'FITC' +``` + +## Move stages + +```python +core.setXYPosition(100, 200) +core.setZPosition(10) +core.waitForSystem() +core.getXYPosition() # [100.005, 199.995] +``` + +## React to changes + +`CMMCorePlus` emits [events](../pymmcore-plus/guides/events.md) whenever the +state of the microscope changes, so your code (or a GUI) can stay in sync: + +```python +@core.events.propertyChanged.connect +def on_change(device: str, prop: str, value: str) -> None: + print(f"{device}.{prop} changed to {value}") + +core.setProperty("Camera", "Gain", "2") # prints "Camera.Gain changed to 2" +``` + +## Where next? + +- Every method of the core, with what `pymmcore-plus` adds or changes, is listed + in the [`CMMCorePlus` reference][pymmcore_plus.CMMCorePlus]. +- [Your first acquisition](first-acquisition.md): run multi-dimensional + experiments. +- The [User Guide](../guide/index.md) covers each topic in depth. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..b60af8e --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,62 @@ +--- +title: Which package do I need? +--- + +# Which package do I need? + +The pymmcore-plus ecosystem is a set of packages that build on each other. +Most people need just one or two of them: + +| If you want to… | Use | Start here | +| --- | --- | --- | +| Control a microscope with a point-and-click app | **pymmcore-gui** | [Applications](../pymmcore-gui/index.md) | +| Control a microscope from inside [napari](https://napari.org) | **napari-micromanager** | [napari-micromanager](../napari-micromanager/index.md) | +| Script your microscope from Python or Jupyter | **pymmcore-plus** | [First steps](first-steps.md) | +| Run multi-dimensional acquisitions from code | **pymmcore-plus** + **useq-schema** | [Your first acquisition](first-acquisition.md) | +| Describe experiments in a hardware-agnostic way | **useq-schema** | [Describing experiments](../useq-schema/schema/sequence.md) | +| Build your own Qt GUI for a microscope | **pymmcore-widgets** | [Your first GUI](first-gui.md) | + +``` mermaid +graph BT + hw[Your hardware] --> mm["Micro-Manager device adapters
(C++, via pymmcore)"] + mm --> plus[pymmcore-plus] + useq[useq-schema] --> plus + plus --> widgets[pymmcore-widgets] + widgets --> gui[pymmcore-gui] + widgets --> napari[napari-micromanager] + click plus "../pymmcore-plus/" + click useq "../useq-schema/" + click widgets "../pymmcore-widgets/widgets/" + click gui "../pymmcore-gui/" + click napari "../napari-micromanager/" +``` + +Installing a package also installs everything it builds on: `pymmcore-widgets` +brings in `pymmcore-plus`, which brings in `useq-schema` and `pymmcore`. The one +thing you always need to install separately is the set of **Micro-Manager device +adapters** (the drivers for your hardware) — see +[Installation](install.md). + +## What each package does + +[pymmcore-plus](../pymmcore-plus/index.md) +: The foundation. [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] is a drop-in + replacement for Micro-Manager's `CMMCore` with a Pythonic API, an event + system, and a flexible acquisition engine. Also includes the `mmcore` + command line tool. + +[useq-schema](../useq-schema/index.md) +: A hardware-agnostic schema for multi-dimensional experiments + ([`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent]). Not + specific to Micro-Manager: any acquisition engine can consume it. + +[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) +: Qt widgets that control a `CMMCorePlus` instance: device and property + controls, configuration editors, MDA editors, image previews, and more. + +[pymmcore-gui](../pymmcore-gui/index.md) +: A complete desktop application combining all of the above (plus the + [ndv](https://github.com/pyapp-kit/ndv) viewer). + +[napari-micromanager](../napari-micromanager/index.md) +: A napari plugin providing microscope control inside the napari viewer. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md new file mode 100644 index 0000000..65ae7cf --- /dev/null +++ b/docs/getting-started/install.md @@ -0,0 +1,77 @@ +--- +title: Installation +--- + +# Installation + +Installation has two parts: + +1. **The Python packages** you want (they pull in everything they build on). +2. **Micro-Manager's device adapters**: the C++ drivers for your hardware, + including a set of demo devices that let you try everything without a + microscope. + +## 1. Install the packages + +Pick what you need and copy the command: + +
+ +=== "pip" + + ```sh + pip install "pymmcore-plus[cli]" + ``` + +=== "uv" + + ```sh + uv add "pymmcore-plus[cli]" + ``` + +
+ +??? info "All the options, spelled out" + + | You want | Command | + | --- | --- | + | The core library and the `mmcore` CLI | `pip install "pymmcore-plus[cli]"` | + | … plus saving to OME-Zarr / OME-TIFF | `pip install "pymmcore-plus[cli,io]"` | + | Qt widgets (choose a Qt backend) | `pip install "pymmcore-widgets[PyQt6]"` or `"pymmcore-widgets[PySide6]"` | + | The pymmcore-gui application | `pip install git+https://github.com/pymmcore-plus/pymmcore-gui` | + | The napari plugin | `pip install "napari-micromanager[pyqt6]"` | + + Packages are on [PyPI](https://pypi.org/user/tlambert03/) and + [conda-forge](https://anaconda.org/conda-forge/pymmcore-plus). Python 3.10 or + newer is required. + +## 2. Install the device adapters + +The `mmcore` command (installed with the `cli` extra above) downloads the +latest compatible Micro-Manager device adapters for your platform: + +```sh +mmcore install +``` + +That's it: `CMMCorePlus` finds them automatically. For details (choosing a +version, using an existing Micro-Manager installation, Linux, and what "device +interface version" means) see +[Micro-Manager device adapters](../pymmcore-plus/install.md). + +!!! tip "Check your setup" + + ```sh + mmcore info # show versions and the active Micro-Manager installation + mmcore bench # run a quick benchmark with the demo devices + ``` + +## Next steps + +
+ +- [:octicons-arrow-right-24: **First steps**](first-steps.md): script the core +- [:octicons-arrow-right-24: **Your first acquisition**](first-acquisition.md) +- [:octicons-arrow-right-24: **Your first GUI**](first-gui.md) + +
diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..eff64f9 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,65 @@ +--- +title: User Guide +--- + +# User Guide + +The User Guide covers the whole ecosystem by topic, rather than by package. +Each page notes which package it is about, and links to the +[API reference](../api/index.md) for details. + +New here? Start with [Getting Started](../getting-started/index.md). + +
+ +- :lucide-sliders-horizontal:{ .lg .middle } **Controlling hardware** + + --- + + Devices and properties, configuration groups, constants, and the event + system that keeps everything in sync. + + [:octicons-arrow-right-24: Devices & properties](../pymmcore-plus/guides/devices.md) + +- :lucide-list-tree:{ .lg .middle } **Describing experiments** + + --- + + `useq-schema`: sequences, events, time/z/grid/position plans, autofocus. + + [:octicons-arrow-right-24: Sequences](../useq-schema/schema/sequence.md) + +- :lucide-play:{ .lg .middle } **Running acquisitions** + + --- + + The acquisition engine, saving data, event-driven (adaptive) acquisition, + and writing your own engine. + + [:octicons-arrow-right-24: The acquisition engine](../pymmcore-plus/guides/mda_engine.md) + +- :lucide-app-window:{ .lg .middle } **Building GUIs** + + --- + + Compose `pymmcore-widgets` into your own Qt application. + + [:octicons-arrow-right-24: Widget gallery](../pymmcore-widgets/widgets/index.md) + +- :lucide-microscope:{ .lg .middle } **Applications** + + --- + + `pymmcore-gui` and `napari-micromanager`. + + [:octicons-arrow-right-24: pymmcore-gui](../pymmcore-gui/index.md) + +- :lucide-cpu:{ .lg .middle } **Advanced topics** + + --- + + Python device adapters (UniMMCore), logging, profiling, and more. + + [:octicons-arrow-right-24: UniMMCore](../pymmcore-plus/guides/unicore.md) + +
diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..47e112b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,140 @@ +--- +title: Home +description: Control microscopes with Python, using Micro-Manager's device adapters. +hide: + - navigation + - toc + - path +--- + +
+ +
+ +# Control microscopes with Python + +**pymmcore-plus** and friends give you pure-Python access to the hundreds of +devices supported by [Micro-Manager](https://micro-manager.org): script your +hardware, describe and run multi-dimensional experiments, and build (or simply +use) graphical interfaces — no Java required. + +[Get started](getting-started/index.md){ .md-button .md-button--primary } +[User guide](guide/index.md){ .md-button } + +
+ +
+ +```python +import useq +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() # demo devices + +experiment = useq.MDASequence( + channels=["DAPI", "FITC"], + z_plan={"range": 4, "step": 0.5}, + time_plan={"interval": 2, "loops": 10}, +) +core.mda.run(experiment, output="data.ome.zarr") +``` + +
+ +
+ +## What would you like to do? + +
+ +- :lucide-terminal:{ .lg .middle } **Script a microscope** + + --- + + Load a configuration, move stages, snap images, and react to every change + from Python or Jupyter. + + [:octicons-arrow-right-24: First steps](getting-started/first-steps.md) + +- :lucide-layers:{ .lg .middle } **Run acquisitions** + + --- + + Describe time-lapses, z-stacks, channels and positions with `useq`, run them + with a hackable acquisition engine, and save to OME-Zarr or OME-TIFF. + + [:octicons-arrow-right-24: Your first acquisition](getting-started/first-acquisition.md) + +- :lucide-app-window:{ .lg .middle } **Build a GUI** + + --- + + Compose ready-made Qt widgets — property browsers, stage controls, + MDA editors, image previews — into your own application. + + [:octicons-arrow-right-24: Your first GUI](getting-started/first-gui.md) + +- :lucide-microscope:{ .lg .middle } **Just use an app** + + --- + + Point-and-click microscope control with `pymmcore-gui`, or inside napari + with `napari-micromanager`. + + [:octicons-arrow-right-24: Applications](pymmcore-gui/index.md) + +
+ +## How the pieces fit together + +Each layer builds on the ones below it, and every layer is usable on its own. +Click a package to learn more. + +
+ +
+
Applications
+
[**pymmcore-gui**](pymmcore-gui/index.md) · [**napari-micromanager**](napari-micromanager/index.md)
+
Complete microscope control applications.
+
+ +
+
Widgets
+
[**pymmcore-widgets**](pymmcore-widgets/widgets/index.md)
+
Qt widgets for devices, properties, configurations and acquisitions.
+
+ +
+
Control & acquisition
+
[**pymmcore-plus**](pymmcore-plus/index.md) · [**useq-schema**](useq-schema/index.md)
+
A Pythonic core with events, an acquisition engine, and a hardware-agnostic schema for experiments.
+
+ +
+
Micro-Manager
+
[**pymmcore**](https://github.com/micro-manager/pymmcore) · [device adapters](pymmcore-plus/install.md)
+
The C++ core and drivers for hundreds of cameras, stages, shutters, lasers…
+
+ +
+
Your microscope
+
:lucide-microscope:   (or the built-in demo devices — no hardware needed)
+
+ +
+ +## Community + +
+ +- :fontawesome-brands-discourse: **Ask a question** on the + [image.sc forum](https://forum.image.sc/tag/pymmcore-plus) + +- :simple-zulip: **Chat with developers** on + [Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D) + +- :fontawesome-brands-github: **Report a bug or request a feature** on + [GitHub](https://github.com/pymmcore-plus) + +
diff --git a/index.html b/index.html deleted file mode 100644 index 08e1a9e..0000000 --- a/index.html +++ /dev/null @@ -1,5 +0,0 @@ - - -Redirecting to https://pymmcore-plus.github.io/pymmcore-plus/ - - diff --git a/justfile b/justfile new file mode 100644 index 0000000..d135549 --- /dev/null +++ b/justfile @@ -0,0 +1,21 @@ +# Unified docs for the pymmcore-plus ecosystem. See docs/contributing/docs.md. + +# clone missing member repos into .repos/ +repos *args: + uv run --no-project tools/repos.py {{ args }} + +# assemble the site and serve it with live reload +serve *args: repos + uv run tools/compose.py serve {{ args }} + +# assemble and build the site into site/ +build *args: repos + uv run tools/compose.py build {{ args }} + +# fail on any warning +check: repos + uv run tools/compose.py build --strict + +# compare the old (live) site's URLs against the new build +check-urls *args: + uv run tools/check_urls.py {{ args }} diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..9ba3275 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block announce %} + The pymmcore-plus, useq-schema, pymmcore-widgets, pymmcore-gui and + napari-micromanager docs now live together on one site. + Explore the new User Guide +{% endblock %} diff --git a/overrides/partials/actions.html b/overrides/partials/actions.html new file mode 100644 index 0000000..74095b5 --- /dev/null +++ b/overrides/partials/actions.html @@ -0,0 +1,26 @@ +{#- + Like zensical's partials/actions.html, but the edit/view links come from the + page's `edit_url` front matter (injected by tools/compose.py), so pages from + member repositories link to their own repository. +-#} +{% set edit_url = (page.meta and page.meta.edit_url) or page.edit_url %} +{% if "content.action.copy" in features and page.markdown_url %} + +{% endif %} +{% if edit_url %} + {% if "content.action.edit" in features %} + + {% set icon = config.theme.icon.edit or "material/file-edit-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} + {% if "content.action.view" in features and "/edit/" in edit_url %} + + {% set icon = config.theme.icon.view or "material/file-eye-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} +{% endif %} diff --git a/overrides/partials/content.html b/overrides/partials/content.html new file mode 100644 index 0000000..2506d15 --- /dev/null +++ b/overrides/partials/content.html @@ -0,0 +1,25 @@ +{#- + Like zensical's partials/content.html, plus a badge naming the package (and + repository) that a member page belongs to. +-#} +{% include "partials/actions.html" %} +{% set member = page.meta and page.meta.package and config.extra.members[page.meta.package] %} +{% if member %} + +{% endif %} +{% if "{{ page.title | d(config.site_name, true)}} +{% endif %} +{{ page.content }} +{% include "partials/tags.html" %} +{% include "partials/source-file.html" %} +{% include "partials/feedback.html" %} +{% include "partials/comments.html" %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a91e26a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +# Environment for building the unified pymmcore-plus ecosystem docs. +# Member repos are checked out (or worktree'd) into .repos/ by tools/repos.py. +[project] +name = "pymmcore-plus-docs" +version = "0" +requires-python = ">=3.12" +dependencies = [ + "zensical ~=0.0.73", + "mkdocstrings-python >=2.0.10", + "griffelib >=2.4.1", + "griffe-fieldz >=0.5", + "pymdown-extensions >=10.16", + "tomli-w >=1.2", + "watchfiles >=1.1", + # members (editable, from .repos/) + "pymmcore-plus[cli]", + "useq-schema", + "pymmcore-widgets", + "PyQt6 >=6.7", +] + +[dependency-groups] +dev = ["ruff >=0.14", "prek >=0.3"] + +[tool.uv] +package = false + +[tool.uv.sources] +pymmcore-plus = { path = ".repos/pymmcore-plus", editable = true } +useq-schema = { path = ".repos/useq-schema", editable = true } +pymmcore-widgets = { path = ".repos/pymmcore-widgets", editable = true } + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "RUF", "TC"] diff --git a/redirects.toml b/redirects.toml new file mode 100644 index 0000000..0518521 --- /dev/null +++ b/redirects.toml @@ -0,0 +1,48 @@ +# Redirects for pages that moved in the unified docs (old path -> new path), +# relative to the site root. Read by tools/compose.py into the redirects plugin. +[redirects] +"pymmcore-plus/api/cmmcoreplus.md" = "pymmcore-plus/api/pymmcore_plus/CMMCorePlus.md" +"pymmcore-plus/api/mda.md" = "pymmcore-plus/api/pymmcore_plus/mda/index.md" +"pymmcore-plus/api/events.md" = "pymmcore-plus/guides/events.md" +"pymmcore-plus/api/device.md" = "pymmcore-plus/guides/devices.md" +"pymmcore-plus/api/configuration.md" = "pymmcore-plus/guides/configuration.md" +"pymmcore-plus/api/constants.md" = "pymmcore-plus/guides/constants.md" +"pymmcore-plus/api/metadata.md" = "pymmcore-plus/api/pymmcore_plus/metadata/index.md" +"pymmcore-plus/api/utils.md" = "pymmcore-plus/api/pymmcore_plus/index.md" +"pymmcore-plus/api/unicore.md" = "pymmcore-plus/api/pymmcore_plus/experimental/unicore/index.md" +"pymmcore-plus/metadata.md" = "pymmcore-plus/api/pymmcore_plus/metadata/index.md" + +# pages that moved into the unified User Guide or API reference +"pymmcore-plus/examples/napari-micromanager.md" = "napari-micromanager/scripting.md" +"useq-schema/api.md" = "useq-schema/api/useq/index.md" + +# per-widget pages now live in the pymmcore-widgets API reference +"pymmcore-widgets/widgets/CameraRoiWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/CameraRoiWidget.md" +"pymmcore-widgets/widgets/ChannelGroupWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ChannelGroupWidget.md" +"pymmcore-widgets/widgets/ChannelTable.md" = "pymmcore-widgets/api/pymmcore_widgets/ChannelTable.md" +"pymmcore-widgets/widgets/ChannelWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ChannelWidget.md" +"pymmcore-widgets/widgets/ConfigWizard.md" = "pymmcore-widgets/api/pymmcore_widgets/ConfigWizard.md" +"pymmcore-widgets/widgets/ConfigurationWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ConfigurationWidget.md" +"pymmcore-widgets/widgets/CoreLogWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/CoreLogWidget.md" +"pymmcore-widgets/widgets/DefaultCameraExposureWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/DefaultCameraExposureWidget.md" +"pymmcore-widgets/widgets/ExposureWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ExposureWidget.md" +"pymmcore-widgets/widgets/GridPlanWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/GridPlanWidget.md" +"pymmcore-widgets/widgets/GroupPresetTableWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/GroupPresetTableWidget.md" +"pymmcore-widgets/widgets/ImagePreview.md" = "pymmcore-widgets/api/pymmcore_widgets/ImagePreview.md" +"pymmcore-widgets/widgets/InstallWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/InstallWidget.md" +"pymmcore-widgets/widgets/LiveButton.md" = "pymmcore-widgets/api/pymmcore_widgets/LiveButton.md" +"pymmcore-widgets/widgets/MDASequenceWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/MDASequenceWidget.md" +"pymmcore-widgets/widgets/MDAWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/MDAWidget.md" +"pymmcore-widgets/widgets/ObjectivesPixelConfigurationWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ObjectivesPixelConfigurationWidget.md" +"pymmcore-widgets/widgets/ObjectivesWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ObjectivesWidget.md" +"pymmcore-widgets/widgets/PixelConfigurationWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/PixelConfigurationWidget.md" +"pymmcore-widgets/widgets/PositionTable.md" = "pymmcore-widgets/api/pymmcore_widgets/PositionTable.md" +"pymmcore-widgets/widgets/PresetsWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/PresetsWidget.md" +"pymmcore-widgets/widgets/PropertiesWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/PropertiesWidget.md" +"pymmcore-widgets/widgets/PropertyBrowser.md" = "pymmcore-widgets/api/pymmcore_widgets/PropertyBrowser.md" +"pymmcore-widgets/widgets/PropertyWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/PropertyWidget.md" +"pymmcore-widgets/widgets/ShuttersWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ShuttersWidget.md" +"pymmcore-widgets/widgets/SnapButton.md" = "pymmcore-widgets/api/pymmcore_widgets/SnapButton.md" +"pymmcore-widgets/widgets/StageWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/StageWidget.md" +"pymmcore-widgets/widgets/TimePlanWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/TimePlanWidget.md" +"pymmcore-widgets/widgets/ZPlanWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/ZPlanWidget.md" diff --git a/tools/api.py b/tools/api.py new file mode 100644 index 0000000..a4e331a --- /dev/null +++ b/tools/api.py @@ -0,0 +1,367 @@ +"""Generate de-duplicated API reference pages for the composed site. + +Members declare a native `api-autonav` block in their zensical.toml. zensical's +own api-autonav renders every public location of an object (so re-exported names +are documented several times), and allows only one api root per build. Here we +emulate it per member, with two extra rules: + +1. **Highest-level name wins.** Each object is rendered once, at its public + location with the fewest dotted parts. Other modules exporting it get a + "Re-exports" table linking there. +2. **Hand-written pages claim objects.** Any `::: identifier` block in a + member's own pages means that page documents the object; the generated module + page links to it instead of rendering it again. A hand-written page at a + module's generated path (e.g. `api/pkg/sub/index.md`) replaces that page. +""" + +from __future__ import annotations + +import json +import re +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import griffe + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + +MOD_PREFIX = "" +KIND_PREFIX = "" +_AUTODOC = re.compile(r"^\s*:::\s*([\w.]+)", re.MULTILINE) +_TITLE = re.compile( + r"^title:\s*\"?([^\"\n]+)\"?\s*$|^#\s+(.+?)(?:\s*\{.*\})?\s*$", re.MULTILINE +) + + +@dataclass +class ApiConfig: + """A member's api-autonav settings (the subset we honor).""" + + member: str + root: Path # member checkout + modules: list[str] + exclude: list[str] = field(default_factory=list) + module_options: dict[str, dict[str, Any]] = field(default_factory=dict) + api_root_uri: str = "api" + exclude_private: bool = True + + @classmethod + def from_member(cls, member: str, root: Path, cfg: dict) -> ApiConfig | None: + if not (cfg and cfg.get("enabled", True)): + return None + return cls( + member=member, + root=root, + modules=list(cfg.get("modules", [])), + exclude=list(cfg.get("exclude", [])), + module_options=dict(cfg.get("module_options", {})), + api_root_uri=cfg.get("api_root_uri", "api"), + exclude_private=cfg.get("exclude_private", True), + ) + + +@dataclass +class Claim: + """An object documented on a hand-written page.""" + + name: str + path: str # identifier rendered by the page + page: str # docs-relative path of the claiming page (incl. mount) + title: str + kind: str + summary: str + + +@dataclass +class ModulePage: + config: ApiConfig + module: griffe.Module + members: list[str] = field(default_factory=list) + reexports: list[tuple[str, str, str]] = field(default_factory=list) + claimed: list[Claim] = field(default_factory=list) + + @property + def path(self) -> str: + return self.module.path + + @property + def is_package(self) -> bool: + return self.module.is_init_module + + def rel_path(self) -> str: + """Member-docs-relative page path, mirroring api-autonav's layout.""" + parts = self.path.split(".") + if self.is_package: + return "/".join([self.config.api_root_uri, *parts, "index.md"]) + return "/".join([self.config.api_root_uri, *parts[:-1], f"{parts[-1]}.md"]) + + def rel_dir(self) -> str: + return self.rel_path().rsplit("/", 1)[0] + + +# --------------------------------------------------------------------------- +# discovery +# --------------------------------------------------------------------------- + + +def _excluded(path: str, cfg: ApiConfig) -> bool: + if cfg.exclude_private and any(p.startswith("_") for p in path.split(".")): + return True + for pattern in cfg.exclude: + if pattern.startswith("re:"): + if re.search(pattern[3:], path): + return True + elif path == pattern or path.startswith(pattern + "."): + return True + return False + + +def _iter_modules(mod: griffe.Module, cfg: ApiConfig) -> Iterator[griffe.Module]: + if _excluded(mod.path, cfg): + return + yield mod + for sub in sorted(mod.modules.values(), key=lambda m: m.name): + if not sub.is_alias: + yield from _iter_modules(sub, cfg) # type: ignore[arg-type] + + +def _summary(obj: griffe.Object | griffe.Alias) -> str: + try: + doc = obj.docstring + except griffe.AliasResolutionError: + return "" + if not doc: + return "" + line = doc.value.strip().split("\n\n")[0].replace("\n", " ").strip() + return line.replace("|", "\\|") + + +def _target_path(obj: griffe.Object | griffe.Alias) -> str | None: + if not obj.is_alias: + return obj.path + try: + return obj.final_target.path # type: ignore[union-attr] + except (griffe.AliasResolutionError, griffe.CyclicAliasError): + return None + + +def _kind(obj: griffe.Object | griffe.Alias) -> str: + try: + return obj.kind.value + except griffe.AliasResolutionError: + return "attribute" + + +def _page_title(text: str, fallback: str) -> str: + match = _TITLE.search(text) + return (match[1] or match[2]).strip() if match else fallback + + +def find_claims(docs: Path, mount: str) -> tuple[dict[str, tuple[str, str]], set[str]]: + """Scan a member's (copied, snippet-expanded) docs for claims. + + Returns `{identifier: (page, title)}` for every `:::` block on a hand-written + page (page paths relative to the mount), and the set of hand-written pages. + """ + identifiers: dict[str, tuple[str, str]] = {} + pages: set[str] = set() + for md in sorted((docs / mount).rglob("*.md")): + text = md.read_text(encoding="utf-8") + if "\ngenerated: true\n" in text[:300]: + continue + rel = md.relative_to(docs / mount).as_posix() + pages.add(rel) + title = _page_title(text, md.stem) + for ident in _AUTODOC.findall(text): + identifiers.setdefault(ident, (rel, title)) + return identifiers, pages + + +def load(configs: Sequence[ApiConfig], loader: griffe.GriffeLoader) -> None: + for cfg in configs: + for mod_path in cfg.modules: + loader.load(Path(mod_path).name) + loader.resolve_aliases(implicit=False, external=None) + + +# --------------------------------------------------------------------------- +# planning +# --------------------------------------------------------------------------- + + +def plan( + configs: Sequence[ApiConfig], + loader: griffe.GriffeLoader, + claims: dict[str, tuple[str, str]], +) -> tuple[dict[str, list[ModulePage]], dict[str, str]]: + """Decide, for every public object, the single page that documents it. + + `claims` maps identifiers rendered by hand-written pages to (page, title). + Returns module pages per member, and a map of every other public path of an + object to the path it is documented at (so references to those resolve). + """ + pages: dict[str, list[ModulePage]] = defaultdict(list) + # target path -> [(public location, module page, name)] + locations: dict[str, list[tuple[str, ModulePage, str]]] = defaultdict(list) + for cfg in configs: + for mod_path in cfg.modules: + pkg = loader.modules_collection[Path(mod_path).name] + for mod in _iter_modules(pkg, cfg): + page = ModulePage(cfg, mod) + pages[cfg.member].append(page) + for name, obj in mod.members.items(): + try: + if not obj.is_public or obj.is_module: + continue + except griffe.AliasResolutionError: + continue # e.g. a stdlib import that isn't loaded + if (target := _target_path(obj)) is None: + print( + f"[api] unresolved alias {mod.path}.{name}", file=sys.stderr + ) + continue + locations[target].append((f"{mod.path}.{name}", page, name)) + + aliases: dict[str, str] = {} + for target, locs in locations.items(): + defining_module = target.rsplit(".", 1)[0] + + def rank( + loc: tuple[str, ModulePage, str], defining: str = defining_module + ) -> tuple[int, int, int, str]: + path, page, _ = loc + return ( + 0 if path in claims else 1, # claimed by a hand-written page + path.count("."), # highest-level public name + 0 if page.path == defining else 1, # where it's defined + path, + ) + + best_path, best_page, best_name = min(locs, key=rank) + best_obj = best_page.module.members[best_name] + summary = _summary(best_obj) + if claim := claims.get(best_path) or claims.get(target): + best_page.claimed.append( + Claim( + best_name, best_path, claim[0], claim[1], _kind(best_obj), summary + ) + ) + else: + best_page.members.append(best_name) + for path, page, name in locs: + if path != best_path: + page.reexports.append((name, best_path, summary)) + aliases[path] = best_path + return pages, aliases + + +# --------------------------------------------------------------------------- +# rendering +# --------------------------------------------------------------------------- + + +def _options(page: ModulePage) -> dict[str, Any]: + options: dict[str, Any] = {"heading_level": 1, "show_root_heading": True} + for pattern, local in page.config.module_options.items(): + if re.match(pattern, page.path): + options.update(local) + options["members"] = sorted(page.members) if page.members else False + options["show_submodules"] = False + return options + + +def render(page: ModulePage) -> str: + title = page.path.rsplit(".", 1)[-1] + lines = [ + "---", + f"title: {json.dumps(title)}", + f"package: {json.dumps(page.config.member)}", + "generated: true", + "---", + "", + f"::: {page.path}", + f" options: {json.dumps(_options(page))}", + "", + ] + slug = page.path.replace(".", "-") + if page.claimed: + lines += [ + f"## Documented on other pages {{#{slug}--elsewhere}}", + "", + "| Object | Page | Description |", + "| --- | --- | --- |", + ] + for c in sorted(page.claimed, key=lambda c: c.name): + lines.append(f"| [`{c.name}`][{c.path}] | {c.title} | {c.summary} |") + lines.append("") + if page.reexports: + lines += [ + f"## Re-exports {{#{slug}--reexports}}", + "", + "These names can be imported from this module, but are documented " + "at their top-level location.", + "", + "| Name | Description |", + "| --- | --- |", + ] + for name, target, summary in sorted(page.reexports): + lines.append(f"| [`{name}`][{target}] | {summary} |") + lines.append("") + return "\n".join(lines) + + +def write( + pages: list[ModulePage], out_docs: Path, mount: str, handwritten: set[str] +) -> list[str]: + """Write generated module pages (and `.nav.yml` files) under out_docs/mount. + + Pages that a member wrote by hand at the same path are left alone. Returns + the docs-relative paths written. + """ + written: list[str] = [] + dirs: dict[str, dict[str, Any]] = defaultdict( + lambda: {"index": None, "modules": [], "subdirs": set(), "objects": []} + ) + for page in pages: + rel = page.rel_path() + entry = dirs[page.rel_dir()] + if page.is_package: + entry["index"] = page + if "." in page.path: + parent, leaf = page.rel_dir().rsplit("/", 1) + dirs[parent]["subdirs"].add(leaf) + # hand-written object pages living in this package's directory + for c in page.claimed: + if c.page.rsplit("/", 1)[0] == f"{mount}/{page.rel_dir()}": + entry["objects"].append(c) + else: + entry["modules"].append(page) + if rel in handwritten: + continue + dest = out_docs / mount / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(render(page), encoding="utf-8") + written.append(f"{mount}/{rel}") + + for rel_dir, entry in dirs.items(): + if entry["index"] is None: + continue + nav: list[Any] = ["index.md"] + for c in sorted(entry["objects"], key=lambda c: c.name): + nav.append( + {KIND_PREFIX.format(kind=c.kind) + c.name: c.page.rsplit("/", 1)[1]} + ) + for mod in sorted(entry["modules"], key=lambda p: p.path): + leaf = mod.path.rsplit(".", 1)[-1] + nav.append({f"{MOD_PREFIX}{leaf}": f"{leaf}.md"}) + nav += sorted(entry["subdirs"]) + title = MOD_PREFIX + entry["index"].path.rsplit(".", 1)[-1] + nav_file = out_docs / mount / rel_dir / ".nav.yml" + nav_file.write_text( + json.dumps({"title": title, "nav": nav}, indent=2), encoding="utf-8" + ) + return written diff --git a/tools/check_urls.py b/tools/check_urls.py new file mode 100644 index 0000000..d3e7a0c --- /dev/null +++ b/tools/check_urls.py @@ -0,0 +1,74 @@ +# /// script +# requires-python = ">=3.11" +# /// +"""Check that every page of the old (separate) doc sites still exists. + +Reads the sitemap.xml of each old site (live, or a local copy) and checks that +the same path exists in the freshly built site/ (as a page or a redirect). + + uv run tools/check_urls.py # after `just build` + uv run tools/check_urls.py --live # against the deployed site instead +""" + +from __future__ import annotations + +import argparse +import re +import sys +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parent.parent +SITE = ROOT / "site" +BASE = "https://pymmcore-plus.github.io" +OLD_SITES = ["pymmcore-plus", "useq-schema", "pymmcore-widgets", "napari-micromanager"] +# the old sitemaps are archived here the first time they are fetched, because +# once the new site is deployed the old sitemaps are gone +ARCHIVE = ROOT / "tools" / "old_sitemaps" + + +def old_urls(site: str) -> list[str]: + cached = ARCHIVE / f"{site}.xml" + if not cached.exists(): + ARCHIVE.mkdir(exist_ok=True) + with urllib.request.urlopen(f"{BASE}/{site}/sitemap.xml", timeout=30) as resp: + cached.write_bytes(resp.read()) + return re.findall(r"(.*?)", cached.read_text(encoding="utf-8")) + + +def exists_locally(path: str) -> bool: + target = SITE / path.strip("/") + return (target / "index.html").exists() or target.is_file() + + +def exists_live(path: str) -> bool: + try: + with urllib.request.urlopen(BASE + path, timeout=30) as resp: + return resp.status == 200 + except Exception: + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--live", action="store_true", help="check the deployed site") + ns = parser.parse_args() + check = exists_live if ns.live else exists_locally + + missing = [] + total = 0 + for site in OLD_SITES: + for url in old_urls(site): + total += 1 + path = urlparse(url).path + if not check(path): + missing.append(path) + for path in missing: + print(f"MISSING {path}") + print(f"{total - len(missing)}/{total} old URLs resolve") + return 1 if missing else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/compose.py b/tools/compose.py new file mode 100644 index 0000000..f72db41 --- /dev/null +++ b/tools/compose.py @@ -0,0 +1,685 @@ +"""Compose the unified pymmcore-plus docs from member repositories. + +Pipeline (see docs/contributing/docs.md for the full story): + +1. generate run each member's `docs/_gen.py` (optional) +2. copy hub docs -> build/docs, member docs -> build/docs//, + expanding `--8<--` snippets relative to each member's root +3. api write de-duplicated API pages (tools/api.py) +4. config merge member zensical.toml contributions into zensical.base.toml +5. build run zensical, then post-process (per-member objects.inv) + +This is a stopgap until zensical supports project composition natively; members +keep self-contained configs so that switch should be cheap. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path +from typing import Any + +import tomli_w + +sys.path.insert(0, str(Path(__file__).parent)) +import api + +ROOT = Path(__file__).resolve().parent.parent +REPOS = ROOT / ".repos" +HUB_DOCS = ROOT / "docs" +OUT_DOCS = ROOT / "build" / "docs" +STAGE = ROOT / "build" / "stage" # assembled here, then synced into OUT_DOCS +SITE = ROOT / "site" +HUB_REPO = "pymmcore-plus/pymmcore-plus.github.io" +SKIP_NAMES = {"__pycache__", "_generated", ".DS_Store", "_gen.py"} +# mkdocstrings handler keys a member may contribute; any other option must match +# the hub's value (there is a single shared handler for the whole site). +MEMBER_HANDLER_KEYS = {"paths", "inventories"} +MEMBER_OPTION_KEYS = {"extensions", "preload_modules"} + + +class ComposeError(RuntimeError): + pass + + +@dataclass +class Member: + name: str + repo: str + title: str + pypi: str = "" + branch: str = "main" + extra: dict[str, Any] = field(default_factory=dict) + + @property + def root(self) -> Path: + return REPOS / self.name + + @property + def docs(self) -> Path: + return self.root / "docs" + + @cached_property + def config(self) -> dict[str, Any]: + path = self.root / "zensical.toml" + if not path.exists(): + return {} + return tomllib.loads(path.read_text(encoding="utf-8")).get("project", {}) + + @property + def plugins(self) -> dict[str, Any]: + return self.config.get("plugins", {}) + + @property + def edit_base(self) -> str: + return f"https://github.com/{self.repo}/edit/{self.branch}/docs/" + + +def load_members(only: str | None = None) -> list[Member]: + data = tomllib.loads((ROOT / "compose.toml").read_text(encoding="utf-8")) + members = [Member(**m) for m in data["member"]] + if only := only or os.environ.get("PYMM_DOCS_ONLY"): + members = [m for m in members if m.name in only.split(",")] + missing = [m.name for m in members if not m.root.exists()] + if missing: + raise ComposeError( + f"missing checkouts in .repos/: {missing} (run tools/repos.py)" + ) + return members + + +# --------------------------------------------------------------------------- +# 1. generate +# --------------------------------------------------------------------------- + + +def generate(members: list[Member], only: set[str] | None = None) -> None: + # offscreen by default (no windows popping up); CI may choose a native platform + env = {"QT_QPA_PLATFORM": "offscreen", **os.environ, "PYTHONDONTWRITEBYTECODE": "1"} + for m in members: + script = m.docs / "_gen.py" + if script.exists() and (only is None or m.name in only): + print(f"[compose] generate {m.name}") + subprocess.run( + [sys.executable, str(script)], cwd=m.root, env=env, check=True + ) + + +# --------------------------------------------------------------------------- +# 2. copy +# --------------------------------------------------------------------------- + +_FRONT_MATTER = re.compile(r"\A---\n(.*?\n)?---\n", re.DOTALL) + + +def _snippet_processor(base_paths: list[Path]) -> Any: + import markdown + + md = markdown.Markdown( + extensions=["pymdownx.snippets"], + extension_configs={ + "pymdownx.snippets": { + "base_path": [str(p) for p in base_paths], + "check_paths": True, + } + }, + ) + return md.preprocessors["snippet"] + + +def _with_meta(text: str, meta: dict[str, str]) -> str: + if m := _FRONT_MATTER.match(text): + body = m.group(1) or "" + extra = "".join( + f"{k}: {json.dumps(v)}\n" + for k, v in meta.items() + if f"\n{k}:" not in f"\n{body}" + ) + if "generated: true" in body: # generated pages have no source to edit + extra = "".join( + line for line in extra.splitlines(True) if "edit_url" not in line + ) + return f"---\n{body}{extra}---\n{text[m.end() :]}" + head = "".join(f"{k}: {json.dumps(v)}\n" for k, v in meta.items()) + return f"---\n{head}---\n{text}" + + +def copy_tree( + src: Path, dest: Path, base_paths: list[Path], meta: dict[str, str], edit_base: str +) -> int: + """Copy a docs folder, expanding snippets and injecting front matter.""" + snippets = _snippet_processor(base_paths) + count = 0 + for path in sorted(src.rglob("*")): + rel = path.relative_to(src) + if any(part in SKIP_NAMES or part.endswith(".py") for part in rel.parts): + continue + out = dest / rel + if path.is_dir(): + continue + out.parent.mkdir(parents=True, exist_ok=True) + if path.suffix == ".md": + out.write_text( + render_page(path, rel, snippets, meta, edit_base), encoding="utf-8" + ) + count += 1 + else: + shutil.copy2(path, out) + return count + + +def render_page( + path: Path, rel: Path, snippets: Any, meta: dict[str, str], edit_base: str +) -> str: + text = path.read_text(encoding="utf-8") + if "--8<--" in text: + text = "\n".join(snippets.run(text.split("\n"))) + return _with_meta(text, {**meta, "edit_url": edit_base + rel.as_posix()}) + + +def copy_all(members: list[Member]) -> None: + if STAGE.exists(): + shutil.rmtree(STAGE) + n = copy_tree( + HUB_DOCS, + STAGE, + [ROOT, REPOS], + {}, + f"https://github.com/{HUB_REPO}/edit/main/docs/", + ) + print(f"[compose] copied {n} hub pages") + for m in members: + if not m.docs.exists(): + continue + n = copy_tree( + m.docs, STAGE / m.name, [m.root], {"package": m.name}, m.edit_base + ) + print(f"[compose] copied {n} pages from {m.name}") + + +def sync_tree(src: Path, dest: Path) -> None: + """Make `dest` identical to `src`, touching only files that changed. + + Rewriting everything would make a running `zensical serve` rebuild (and + possibly read half-written files) on every re-assembly. + """ + src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()} + dest_files = {p.relative_to(dest) for p in dest.rglob("*") if p.is_file()} + for rel in dest_files - src_files: + (dest / rel).unlink() + for rel in src_files: + a, b = src / rel, dest / rel + if not b.exists() or a.read_bytes() != b.read_bytes(): + b.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(a, b) + for d in sorted((p for p in dest.rglob("*") if p.is_dir()), reverse=True): + if not any(d.iterdir()): + d.rmdir() + + +# --------------------------------------------------------------------------- +# 3. api +# --------------------------------------------------------------------------- + + +def api_configs(members: list[Member]) -> list[api.ApiConfig]: + out = [] + for m in members: + if cfg := api.ApiConfig.from_member( + m.name, m.root, m.plugins.get("api-autonav", {}) + ): + cfg.modules = [str(m.root / p) for p in cfg.modules] + out.append(cfg) + return out + + +def write_api(members: list[Member], config: dict[str, Any]) -> None: + import griffe + + configs = api_configs(members) + if not configs: + return + handler = config["plugins"]["mkdocstrings"]["handlers"]["python"] + options = handler["options"] + extensions = [_abs_ext(e) for e in options["extensions"] if not _is_fieldz(e)] + loader = griffe.GriffeLoader( + extensions=griffe.load_extensions(*extensions), + search_paths=[str(ROOT / p) for p in handler["paths"]] + sys.path, + allow_inspection=options.get("allow_inspection", True), + ) + for mod in options.get("preload_modules", []): + loader.load(mod) + api.load(configs, loader) + claims: dict[str, tuple[str, str]] = {} + handwritten: dict[str, set[str]] = {} + for m in members: + ids, pages = api.find_claims(STAGE, m.name) + claims.update( + {k: (f"{m.name}/{page}", title) for k, (page, title) in ids.items()} + ) + handwritten[m.name] = pages + by_member, aliases = api.plan(configs, loader, claims) + (ROOT / "build" / "aliases.json").write_text( + json.dumps(aliases, indent=1), encoding="utf-8" + ) + for m in members: + if pages := by_member.get(m.name): + written = api.write(pages, STAGE, m.name, handwritten[m.name]) + print(f"[compose] wrote {len(written)} API pages for {m.name}") + + +def _abs_ext(ext: Any) -> Any: + if isinstance(ext, str) and ".py" in ext: + return str(ROOT / ext) + if isinstance(ext, dict): + return {(str(ROOT / k) if ".py" in k else k): v for k, v in ext.items()} + return ext + + +def _is_fieldz(ext: Any) -> bool: + return (isinstance(ext, dict) and "griffe_fieldz" in ext) or ext == "griffe_fieldz" + + +# --------------------------------------------------------------------------- +# 4. config +# --------------------------------------------------------------------------- + + +def _prefix(member: Member, path: str) -> str: + return (Path(".repos") / member.name / path).as_posix() + + +def _merge_extension(member: Member, ext: Any) -> Any: + """Make file-based griffe extensions (`docs/x.py:Cls`) hub-relative.""" + if isinstance(ext, str) and ".py" in ext: + return _prefix(member, ext) + if isinstance(ext, dict): + return {(_prefix(member, k) if ".py" in k else k): v for k, v in ext.items()} + return ext + + +def build_config(members: list[Member]) -> dict[str, Any]: + base = tomllib.loads((ROOT / "zensical.base.toml").read_text(encoding="utf-8")) + project = base["project"] + handler = project["plugins"]["mkdocstrings"]["handlers"]["python"] + options = handler["options"] + own_urls = { + f"https://pymmcore-plus.github.io/{m.name}/objects.inv" for m in members + } + + for m in members: + mcfg = m.plugins.get("mkdocstrings", {}).get("handlers", {}).get("python", {}) + for key in mcfg: + if key != "options" and key not in MEMBER_HANDLER_KEYS: + raise ComposeError( + f"{m.name}: handler key {key!r} must be set in the hub" + ) + handler["paths"] += [_prefix(m, p) for p in mcfg.get("paths", [])] + for inv in mcfg.get("inventories", []): + url = inv if isinstance(inv, str) else inv.get("url") + if url not in own_urls and inv not in handler["inventories"]: + handler["inventories"].append(inv) + for key, value in mcfg.get("options", {}).items(): + if key == "extensions": + options["extensions"] += [ + e + for e in (_merge_extension(m, x) for x in value) + if e not in options["extensions"] + ] + elif key == "preload_modules": + options["preload_modules"] += [ + v for v in value if v not in options["preload_modules"] + ] + elif options.get(key) != value: + raise ComposeError( + f"{m.name}: mkdocstrings option {key}={value!r} differs from the " + f"hub ({options.get(key)!r}); set it per `:::` block instead" + ) + for plugin in m.plugins: + if plugin not in project["plugins"] and plugin != "api-autonav": + raise ComposeError( + f"{m.name}: plugin {plugin!r} must be enabled in the hub" + ) + + # api pages are generated by tools/api.py, not zensical's api-autonav + project["plugins"].pop("api-autonav", None) + _check_plugins(project["plugins"]) + + redirects_file = ROOT / "redirects.toml" + if redirects_file.exists(): + maps = tomllib.loads(redirects_file.read_text(encoding="utf-8")).get( + "redirects", {} + ) + project["plugins"]["redirects"]["redirect_maps"] = maps + project.setdefault("extra", {})["members"] = { + m.name: {"title": m.title, "repo": m.repo, "pypi": m.pypi} for m in members + } + project["watch"] = [p for p in handler["paths"]] + ["docs", "overrides"] + return project + + +def _check_plugins(plugins: dict[str, Any]) -> None: + try: + from zensical.config import _PLUGIN_UNSUPPORTED_OPTIONS as supported + except ImportError: # private API; skip the check if it moves + return + if unknown := set(plugins) - set(supported): + raise ComposeError( + f"plugins not supported by zensical (silently ignored): {unknown}" + ) + + +def write_config(project: dict[str, Any]) -> Path: + path = ROOT / "zensical.toml" + tmp = path.with_suffix(".tmp") + header = "# generated by tools/compose.py -- do not edit\n" + tmp.write_text(header + tomli_w.dumps({"project": project}), encoding="utf-8") + tmp.replace(path) # atomic, so `zensical serve` never sees a partial file + return path + + +# --------------------------------------------------------------------------- +# checks +# --------------------------------------------------------------------------- + +_HEADING_ID = re.compile(r"^#{1,6} .*\{[^}]*#([\w.:-]+)[^}]*\}\s*$", re.MULTILINE) +_AUTODOC = re.compile(r"^\s*:::\s*([\w.]+)", re.MULTILINE) + + +def check_sources() -> list[str]: + """Catch problems that zensical's --strict doesn't.""" + problems: list[str] = [] + ids: dict[str, str] = {} + autodocs: dict[str, str] = {} + for md in OUT_DOCS.rglob("*.md"): + rel = md.relative_to(OUT_DOCS).as_posix() + text = md.read_text(encoding="utf-8") + for match in _HEADING_ID.finditer(text): + if (prev := ids.setdefault(match[1], rel)) != rel: + problems.append(f"duplicate heading id #{match[1]}: {prev} and {rel}") + for match in _AUTODOC.finditer(text): + # same object rendered on two pages (claims may split blocks on one page) + if (prev := autodocs.setdefault(match[1], rel)) != rel: + problems.append(f"`::: {match[1]}` rendered in {prev} and {rel}") + problems += _check_nav_duplicates() + return list(dict.fromkeys(problems)) + + +def _check_nav_duplicates() -> list[str]: + import yaml + + seen: dict[str, str] = {} + problems = [] + + def walk(items: Any, base: Path, src: str) -> None: + for item in items or []: + values = item.values() if isinstance(item, dict) else [item] + for v in values: + if isinstance(v, list): + walk(v, base, src) + elif isinstance(v, str) and v.endswith(".md") and "*" not in v: + target = (base / v).resolve().relative_to(OUT_DOCS).as_posix() + if (prev := seen.setdefault(target, src)) != src: + problems.append(f"{target} listed in both {prev} and {src}") + + for nav in OUT_DOCS.rglob(".nav.yml"): + data = yaml.safe_load(nav.read_text(encoding="utf-8")) or {} + walk(data.get("nav"), nav.parent, nav.relative_to(OUT_DOCS).as_posix()) + return problems + + +def check_orphans() -> list[str]: + """Built pages that are not reachable from the navigation.""" + orphans = [] + for html in SITE.rglob("index.html"): + rel = html.relative_to(SITE).as_posix() + text = html.read_text(encoding="utf-8", errors="ignore") + if 'http-equiv="refresh"' in text: # redirect stub + continue + if "md-nav__item--active" not in text and rel != "index.html": + orphans.append(rel) + return [f"not in nav: {o}" for o in sorted(orphans)] + + +# --------------------------------------------------------------------------- +# 5. postbuild +# --------------------------------------------------------------------------- + + +def split_inventory(members: list[Member]) -> None: + """Write //objects.inv so existing intersphinx/mkdocstrings users work.""" + from mkdocstrings import Inventory + + root_inv = SITE / "objects.inv" + if not root_inv.exists(): + return + with root_inv.open("rb") as f: + inventory = Inventory.parse_sphinx(f) + for m in members: + sub = Inventory(project=m.title, version="latest") + prefix = f"{m.name}/" + for item in inventory.values(): + if item.uri.startswith(prefix): + item.uri = item.uri[len(prefix) :] + sub[item.name] = item + if len(sub): + (SITE / m.name).mkdir(exist_ok=True) + (SITE / m.name / "objects.inv").write_bytes(sub.format_sphinx()) + print(f"[compose] {m.name}/objects.inv: {len(sub)} objects") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def assemble(members: list[Member], *, regenerate: bool = True) -> dict[str, Any]: + if regenerate: + generate(members) + copy_all(members) + project = build_config(members) + write_api(members, project) + OUT_DOCS.mkdir(parents=True, exist_ok=True) + sync_tree(STAGE, OUT_DOCS) + write_config(project) + return project + + +def _zensical_env() -> dict[str, str]: + # tools/ on the path so `pymm_mdx` (our Markdown extension) is importable + path = os.pathsep.join( + filter(None, [str(ROOT / "tools"), os.environ.get("PYTHONPATH")]) + ) + return {**os.environ, "PYTHONPATH": path, "PYTHONDONTWRITEBYTECODE": "1"} + + +def _zensical(*args: str) -> list[str]: + exe = shutil.which("zensical", path=str(Path(sys.executable).parent)) + return [exe or "zensical", *args] + + +def cmd_build(ns: argparse.Namespace) -> int: + members = load_members(ns.only) + assemble(members, regenerate=not ns.no_gen) + problems = check_sources() + args = ["build", "--clean"] + (["--strict"] if ns.strict and not ns.scope else []) + proc = subprocess.run( + _zensical(*args), cwd=ROOT, text=True, capture_output=True, env=_zensical_env() + ) + output = proc.stdout + proc.stderr + print(output, end="") + if proc.returncode: + return proc.returncode + split_inventory(members) + problems += check_orphans() + if ns.scope: + problems += [w for w in _warnings(output) if _in_scope(w, ns.scope, members)] + for p in problems: + print(f"[compose] PROBLEM: {p}", file=sys.stderr) + return 1 if (problems and (ns.strict or ns.scope)) else 0 + + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def _warnings(output: str) -> list[str]: + """Warnings from zensical (incl. their source location) and griffe.""" + lines = [_ANSI.sub("", line) for line in output.splitlines()] + found = [] + for i, line in enumerate(lines): + if line.startswith("Warning"): + # the location may be on the next lines: "╭─[ member/page.md:1:2 ]" + found.append(" ".join(x.strip() for x in lines[i : i + 3])) + elif line.startswith("griffe:"): + found.append(line) + return found + + +def _in_scope(warning: str, scope: str, members: list[Member]) -> bool: + if scope == "hub": + return not any(f"{m.name}/" in warning for m in members) + return f"{scope}/" in warning + + +def cmd_serve(ns: argparse.Namespace) -> int: + from watchfiles import watch + + members = load_members(ns.only) + assemble(members, regenerate=not ns.no_gen) + serve = _zensical("serve", "-a", ns.addr) + proc = subprocess.Popen(serve, cwd=ROOT, env=_zensical_env()) + watched = [HUB_DOCS, ROOT / "zensical.base.toml", ROOT / "redirects.toml"] + for m in members: + watched += [ + m.docs, + m.root / "zensical.toml", + m.root / "src", + m.root / "examples", + m.root / "README.md", + ] + paths = [p for p in watched if p.exists()] + try: + for changes in watch( + *paths, watch_filter=_watch_filter, yield_on_timeout=True, rust_timeout=2000 + ): + if changes: + _on_change({Path(p) for _, p in changes}, members) + if proc.poll() is not None: # zensical died (e.g. files vanished mid-build) + print("[compose] zensical exited; restarting") + proc = subprocess.Popen(serve, cwd=ROOT, env=_zensical_env()) + except KeyboardInterrupt: + pass + finally: + proc.terminate() + return 0 + + +def _watch_filter(change: Any, path: str) -> bool: + return not any(s in path for s in ("__pycache__", "/_generated/", ".DS_Store", "~")) + + +def _on_change(paths: set[Path], members: list[Member]) -> None: + configs = {ROOT / "zensical.base.toml", ROOT / "redirects.toml"} + src_changed = { + m.name for m in members for p in paths if p.is_relative_to(m.root / "src") + } + if paths & configs or any(p.name == "zensical.toml" for p in paths) or src_changed: + # src changes can affect generated pages (e.g. the CMMCorePlus table) + for m in members: + m.__dict__.pop("config", None) + if src_changed: + generate(members, only=src_changed) + assemble(members, regenerate=False) + print("[compose] re-assembled") + return + for path in paths: + _recopy(path, members) + + +def _recopy(path: Path, members: list[Member]) -> None: + if path.is_relative_to(HUB_DOCS): + src_root, dest, bases, meta, edit = ( + HUB_DOCS, + OUT_DOCS, + [ROOT, REPOS], + {}, + f"https://github.com/{HUB_REPO}/edit/main/docs/", + ) + else: + m = next((m for m in members if path.is_relative_to(m.root)), None) + if m is None: + return + if not path.is_relative_to(m.docs): + # an example/README changed: re-render pages that may include it + copy_tree( + m.docs, OUT_DOCS / m.name, [m.root], {"package": m.name}, m.edit_base + ) + print(f"[compose] re-copied {m.name} (snippet source changed)") + return + src_root, dest, bases, meta, edit = ( + m.docs, + OUT_DOCS / m.name, + [m.root], + {"package": m.name}, + m.edit_base, + ) + rel = path.relative_to(src_root) + out = dest / rel + if not path.exists(): + out.unlink(missing_ok=True) + elif path.suffix == ".md": + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + render_page(path, rel, _snippet_processor(bases), meta, edit), + encoding="utf-8", + ) + elif path.is_file() and not any(p in SKIP_NAMES for p in rel.parts): + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, out) + print(f"[compose] updated {out.relative_to(OUT_DOCS)}") + + +def cmd_check(ns: argparse.Namespace) -> int: + members = load_members(ns.only) + assemble(members, regenerate=not ns.no_gen) + problems = check_sources() + for p in problems: + print(f"[compose] PROBLEM: {p}", file=sys.stderr) + return 1 if problems else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + b = sub.add_parser("build", help="assemble and build the site") + b.add_argument("--strict", action="store_true", help="fail on any warning") + b.add_argument("--scope", help="fail only on warnings from this member (or 'hub')") + s = sub.add_parser("serve", help="assemble, serve, and live-reload") + s.add_argument("-a", "--addr", default="localhost:8000") + c = sub.add_parser("check", help="assemble and run source checks only") + for p in (b, s, c): + p.add_argument( + "--no-gen", action="store_true", help="skip member _gen.py scripts" + ) + p.add_argument( + "--only", help="comma-separated members to include (quick previews)" + ) + ns = parser.parse_args(argv) + try: + return {"build": cmd_build, "serve": cmd_serve, "check": cmd_check}[ns.cmd](ns) + except ComposeError as e: + print(f"[compose] error: {e}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/old_sitemaps/napari-micromanager.xml b/tools/old_sitemaps/napari-micromanager.xml new file mode 100644 index 0000000..84552d8 --- /dev/null +++ b/tools/old_sitemaps/napari-micromanager.xml @@ -0,0 +1,11 @@ + + + + https://pymmcore-plus.github.io/napari-micromanager/ + 2026-05-21 + + + https://pymmcore-plus.github.io/napari-micromanager/getting_started/ + 2026-05-21 + + \ No newline at end of file diff --git a/tools/old_sitemaps/pymmcore-plus.xml b/tools/old_sitemaps/pymmcore-plus.xml new file mode 100644 index 0000000..a3da883 --- /dev/null +++ b/tools/old_sitemaps/pymmcore-plus.xml @@ -0,0 +1,123 @@ + + + + https://pymmcore-plus.github.io/pymmcore-plus/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/cli/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/contributing/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/env_var/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/install/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/metadata/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/profiling/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/troubleshooting/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/cmmcoreplus/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/configuration/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/constants/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/device/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/events/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/mda/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/metadata/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/unicore/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/api/utils/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/context-set/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/following_changes_in_core/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/integration-with-qt/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/mda/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/napari-micromanager/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/examples/pycro-manager-api/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/custom_engine/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/event_driven_acquisition/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/events/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/ipython_completion/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/logging/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/mda_engine/ + 2026-10-26 + + + https://pymmcore-plus.github.io/pymmcore-plus/guides/unicore/ + 2026-10-26 + + \ No newline at end of file diff --git a/tools/old_sitemaps/pymmcore-widgets.xml b/tools/old_sitemaps/pymmcore-widgets.xml new file mode 100644 index 0000000..8d54915 --- /dev/null +++ b/tools/old_sitemaps/pymmcore-widgets.xml @@ -0,0 +1,139 @@ + + + + https://pymmcore-plus.github.io/pymmcore-widgets/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/contributing/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/getting_started/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/troubleshooting/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/CameraRoiWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ChannelGroupWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ChannelTable/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ChannelWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ConfigWizard/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ConfigurationWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/CoreLogWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/DefaultCameraExposureWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ExposureWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/GridPlanWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/GroupPresetTableWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ImagePreview/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/InstallWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/LiveButton/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/MDASequenceWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/MDAWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ObjectivesPixelConfigurationWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ObjectivesWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PixelConfigurationWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PositionTable/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PresetsWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PropertiesWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PropertyBrowser/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/PropertyWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ShuttersWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/SnapButton/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/StageWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/TimePlanWidget/ + 2026-07-19 + + + https://pymmcore-plus.github.io/pymmcore-widgets/widgets/ZPlanWidget/ + 2026-07-19 + + \ No newline at end of file diff --git a/tools/old_sitemaps/useq-schema.xml b/tools/old_sitemaps/useq-schema.xml new file mode 100644 index 0000000..1406945 --- /dev/null +++ b/tools/old_sitemaps/useq-schema.xml @@ -0,0 +1,31 @@ + + + + https://pymmcore-plus.github.io/useq-schema/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/api/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/v2-migration/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/schema/axes/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/schema/event/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/schema/hardware_autofocus/ + 2026-10-26 + + + https://pymmcore-plus.github.io/useq-schema/schema/sequence/ + 2026-10-26 + + \ No newline at end of file diff --git a/tools/pymm_mdx.py b/tools/pymm_mdx.py new file mode 100644 index 0000000..1618c44 --- /dev/null +++ b/tools/pymm_mdx.py @@ -0,0 +1,92 @@ +"""Python-Markdown extension for the unified pymmcore-plus docs. + +zensical has no page hooks, but it renders pages with Python-Markdown, so a +postprocessor sees each page's final HTML. We use that to: + +1. Register *re-export* paths as secondary autorefs anchors. tools/api.py + renders each object once, at its highest-level public name, and writes + build/aliases.json (`{"pymmcore_plus.core.Device": "pymmcore_plus.Device"}`). + Here, every rendered heading id under a preferred path is also registered + under each alias path, so `[x][pymmcore_plus.core.Device.getPropertyObject]` + resolves to the `pymmcore_plus.Device.getPropertyObject` heading. +2. Decorate "**Why Override?**" paragraphs in CMMCorePlus docstrings. +3. Relabel "Attributes" as "Keys" on the metadata (TypedDict) reference page. +""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict +from functools import cache +from pathlib import Path +from typing import Any + +from markdown import Extension, Markdown +from markdown.postprocessors import Postprocessor + +ALIASES = Path(__file__).resolve().parent.parent / "build" / "aliases.json" +_ID = re.compile(r'\bid="([A-Za-z_][\w.]*)"') +WHY = "Why Override?" +WHY_HTML = ( + '' + '' + f"{WHY}" +) + + +@cache +def _reverse_aliases() -> dict[str, tuple[str, ...]]: + """Map preferred path -> alias paths.""" + if not ALIASES.exists(): + return {} + reverse: dict[str, list[str]] = defaultdict(list) + for alias, preferred in json.loads(ALIASES.read_text(encoding="utf-8")).items(): + reverse[preferred].append(alias) + return {k: tuple(v) for k, v in reverse.items()} + + +def _current_page() -> Any: + try: + from zensical.extensions.autorefs import get_autorefs_store + except ImportError: # not running under zensical + return None, None + store = get_autorefs_store() + return store, store.current_page + + +class PymmPostprocessor(Postprocessor): + def run(self, text: str) -> str: + store, page = _current_page() + if page is None: + return text + if reverse := _reverse_aliases(): + for anchor in set(_ID.findall(text)): + parts = anchor.split(".") + for i in range(len(parts), 0, -1): + if aliases := reverse.get(".".join(parts[:i])): + rest = ".".join(parts[i:]) + for alias in aliases: + ident = f"{alias}.{rest}" if rest else alias + store.register_anchor(page, ident, anchor, primary=False) + break + if WHY in text: # idempotent: mkdocstrings nests Markdown conversions + text = re.sub(r"(?)" + re.escape(WHY), WHY_HTML, text) + src = str(getattr(page, "file", "") or getattr(page, "url", "")) + if "pymmcore_plus/metadata" in src or "pymmcore_plus/metadata" in page.url: + text = text.replace("Attributes:", "Keys:") + text = re.sub( + r"(

)Attributes:", r"\1Keys:", text + ) + return text + + +class PymmExtension(Extension): + def extendMarkdown(self, md: Markdown) -> None: + # after raw HTML (incl. mkdocstrings output) is restored (priority 30) + md.postprocessors.register(PymmPostprocessor(md), "pymm", 5) + + +def makeExtension(**kwargs: Any) -> PymmExtension: + return PymmExtension(**kwargs) diff --git a/tools/repos.py b/tools/repos.py new file mode 100644 index 0000000..9915666 --- /dev/null +++ b/tools/repos.py @@ -0,0 +1,108 @@ +# /// script +# requires-python = ">=3.11" +# /// +"""Check out the member repositories listed in compose.toml into .repos/. + + uv run tools/repos.py # shallow-clone missing members + uv run tools/repos.py --update # also pull existing clones + uv run tools/repos.py --worktrees ~/dev # git worktrees from local clones + uv run tools/repos.py --pr pymmcore-plus=someone/pymmcore-plus@ + +`--worktrees DIR` expects existing clones at DIR/ and creates a worktree +of each in .repos/ (on a `docs` branch from the clone's default +upstream), so you can edit docs and source while previewing the whole site. +`--pr` checks out one member at a specific repository and ref (used by CI to +build a pull request). + +An optional `compose.overrides.toml` (`[overrides] name = "owner/repo@ref"`) +changes where *fresh* clones come from, e.g. to preview unmerged member branches. +It never touches existing checkouts. +""" + +from __future__ import annotations + +import argparse +import subprocess +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +REPOS = ROOT / ".repos" + + +def git(*args: str, cwd: Path | None = None) -> None: + print("+ git", " ".join(args)) + subprocess.run(["git", *args], cwd=cwd, check=True) + + +def clone(dest: Path, repo: str, ref: str, upstream: str) -> None: + """Clone `repo` at `ref`, with full (blob-less) history and upstream's tags. + + Members get their version from git tags (hatch-vcs), and pymmcore-widgets + requires a recent pymmcore-plus, so shallow clones (version 0.1.dev1) or + forks without recent tags would not resolve. + """ + git("init", "-q", str(dest)) + git("remote", "add", "origin", f"https://github.com/{repo}.git", cwd=dest) + git("fetch", "-q", "--filter=blob:none", "origin", ref, cwd=dest) + git("checkout", "-q", "FETCH_HEAD", cwd=dest) + tags = f"https://github.com/{upstream}.git" + git("fetch", "-q", "--filter=blob:none", "--tags", tags, cwd=dest) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--update", action="store_true", help="pull existing clones") + parser.add_argument( + "--worktrees", + type=Path, + metavar="DIR", + help="create worktrees from local clones in DIR", + ) + parser.add_argument( + "--pr", + action="append", + default=[], + metavar="NAME=REPO@REF", + help="check out a member at a specific repository/ref", + ) + ns = parser.parse_args() + + members = tomllib.loads((ROOT / "compose.toml").read_text(encoding="utf-8"))[ + "member" + ] + + def parse(target: str) -> tuple[str, str]: + repo, _, ref = target.partition("@") + return repo, ref or "main" + + file_overrides = {} + if (path := ROOT / "compose.overrides.toml").exists(): + data = tomllib.loads(path.read_text(encoding="utf-8")).get("overrides", {}) + file_overrides = {name: parse(target) for name, target in data.items()} + overrides = {} + for spec in ns.pr: + name, _, target = spec.partition("=") + overrides[name] = parse(target) + + REPOS.mkdir(exist_ok=True) + for m in members: + name, dest = m["name"], REPOS / m["name"] + default = file_overrides.get(name, (m["repo"], m.get("branch", "main"))) + repo, ref = overrides.get(name, default) + if name in overrides and dest.exists(): + url = f"https://github.com/{repo}.git" + git("fetch", "-q", "--filter=blob:none", url, ref, cwd=dest) + git("checkout", "-q", "FETCH_HEAD", cwd=dest) + elif dest.exists(): + if ns.update and not ns.worktrees: + git("pull", "-q", "--ff-only", cwd=dest) + elif ns.worktrees: + local = ns.worktrees.expanduser() / name + git("worktree", "add", str(dest), "-b", "docs", cwd=local) + else: + clone(dest, repo, ref, upstream=m["repo"]) + + +if __name__ == "__main__": + main() diff --git a/zensical.base.toml b/zensical.base.toml new file mode 100644 index 0000000..d455c45 --- /dev/null +++ b/zensical.base.toml @@ -0,0 +1,198 @@ +# Base configuration for the unified pymmcore-plus docs. +# +# tools/compose.py merges member contributions (mkdocstrings paths, griffe +# extensions, inventories) into this file and writes ./zensical.toml. +# Navigation lives in docs/**/.nav.yml (awesome-nav), not here. + +[project] +site_name = "pymmcore-plus" +site_description = "Control microscopes with Python: Micro-Manager's core, acquisition engine, widgets, and apps." +site_url = "https://pymmcore-plus.github.io/" +repo_url = "https://github.com/pymmcore-plus/pymmcore-plus" +repo_name = "pymmcore-plus" +edit_uri = "" # per-page edit links come from front matter +copyright = "Copyright © pymmcore-plus contributors" +docs_dir = "build/docs" +site_dir = "site" +extra_css = ["assets/css/pymm.css"] +extra_javascript = ["assets/js/pymm.js"] + +[project.theme] +custom_dir = "overrides" +variant = "modern" +icon.logo = "lucide/microscope" +favicon = "assets/img/favicon.ico" +features = [ + "announce.dismiss", + "content.action.copy", + "content.action.edit", + "content.action.view", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.instant.progress", + "navigation.path", + "navigation.sections", + "navigation.tabs", + "navigation.tabs.sticky", + "navigation.top", + "navigation.tracking", + "search.highlight", + "toc.follow", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.extra] +generator = false + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/pymmcore-plus" +name = "pymmcore-plus on GitHub" + +[[project.extra.social]] +icon = "fontawesome/brands/discourse" +link = "https://forum.image.sc/tag/pymmcore-plus" +name = "image.sc forum" + +[[project.extra.social]] +icon = "simple/zulip" +link = "https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D" +name = "Zulip chat" + +# --------------------------------------------------------------------------- +# Markdown +# --------------------------------------------------------------------------- + +[project.markdown_extensions.abbr] +[project.markdown_extensions.admonition] +[project.markdown_extensions.attr_list] +[project.markdown_extensions.def_list] +[project.markdown_extensions.footnotes] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.tables] +[project.markdown_extensions.toc] +permalink = true +[project.markdown_extensions."pymdownx.betterem"] +[project.markdown_extensions."pymdownx.caret"] +[project.markdown_extensions."pymdownx.details"] +[project.markdown_extensions."pymdownx.emoji"] +emoji_index = "zensical.extensions.emoji.twemoji" +emoji_generator = "zensical.extensions.emoji.to_svg" +[project.markdown_extensions."pymdownx.highlight"] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true +[project.markdown_extensions."pymdownx.inlinehilite"] +[project.markdown_extensions."pymdownx.keys"] +[project.markdown_extensions."pymdownx.magiclink"] +[project.markdown_extensions."pymdownx.mark"] +[project.markdown_extensions."pymdownx.smartsymbols"] +[project.markdown_extensions."pymdownx.superfences"] +custom_fences = [{ name = "mermaid", class = "mermaid" }] +[project.markdown_extensions."pymdownx.tabbed"] +alternate_style = true +combine_header_slug = true +[project.markdown_extensions."pymdownx.tasklist"] +custom_checkbox = true +[project.markdown_extensions."pymdownx.tilde"] +[project.markdown_extensions."zensical.extensions.preview"] +[project.markdown_extensions.pymm_mdx] # tools/pymm_mdx.py + +# --------------------------------------------------------------------------- +# Plugins +# --------------------------------------------------------------------------- + +[project.plugins.search] + +[project.plugins.awesome-nav] + +[project.plugins.autorefs] +resolve_closest = true + +[project.plugins.glightbox] + +[project.plugins.mkdocs-video] +is_video = true +video_muted = true +video_controls = false +video_autoplay = true +video_loop = true + +[project.plugins.llmstxt] +markdown_description = "Python tools for controlling microscopes with Micro-Manager: pymmcore-plus (core + acquisition engine), useq-schema (experiment schema), pymmcore-widgets (Qt widgets), pymmcore-gui and napari-micromanager (applications)." +full_output = "llms-full.txt" + +[project.plugins.llmstxt.sections] +"Getting started" = ["getting-started/*.md"] +"User guide" = ["*/guides/*.md", "useq-schema/schema/*.md"] +"API reference" = ["*/api/**/*.md"] + +[project.plugins.redirects.redirect_maps] +# filled in by tools/compose.py from redirects.toml + +[project.plugins.mkdocstrings] +enable_inventory = true + +[project.plugins.mkdocstrings.handlers.python] +paths = [] +inventories = [ + "https://docs.python.org/3/objects.inv", + "https://numpy.org/doc/stable/objects.inv", + "https://psygnal.readthedocs.io/en/latest/objects.inv", + "https://pyapp-kit.github.io/superqt/objects.inv", + "https://docs.pydantic.dev/latest/objects.inv", + "https://pymmcore-plus.github.io/ome-writers/objects.inv", +] + +[project.plugins.mkdocstrings.handlers.python.options] +allow_inspection = false +backlinks = "tree" +docstring_section_style = "table" +docstring_style = "numpy" +filters = ["!^_"] +heading_level = 2 +inherited_members = false +line_length = 80 +members_order = "source" +merge_init_into_class = true +modernize_annotations = true +separate_signature = true +show_bases = true +show_if_no_docstring = true +show_root_heading = true +show_signature_annotations = true +show_source = false +show_symbol_type_heading = true +show_symbol_type_toc = true +signature_crossrefs = true +summary = true +extensions = [{ griffe_fieldz = { include_inherited = true, strip_annotated = true } }] +preload_modules = [] From fc03d0251d17baf5458eb87e230e6441a58eb269 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 21:01:17 +0100 Subject: [PATCH 02/15] fix: install builder only lists the top-most packages pymmcore-gui already brings in pymmcore-widgets, pymmcore-plus[cli,io] and PyQt6, and napari-micromanager / pymmcore-widgets bring in pymmcore-plus[cli], so the builder no longer repeats them. Implied options show as included, and the Qt backend is fixed to PyQt6 when pymmcore-gui is selected. Also caches pyconify's icon downloads between CI builds. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 8 +++++ docs/assets/js/pymm.js | 64 +++++++++++++++++++++++++-------- docs/getting-started/install.md | 5 +++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7cc9ae..4737228 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,9 +40,17 @@ jobs: shell: bash env: PYTHONUTF8: "1" + # icons fetched from the Iconify API by some widgets (via pyconify) + PYCONIFY_CACHE: ${{ github.workspace }}/../pyconify-cache steps: - run: echo "T0=$SECONDS" >> "$GITHUB_ENV" + - uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/../pyconify-cache + key: pyconify-${{ runner.os }}-${{ github.run_id }} + restore-keys: pyconify-${{ runner.os }}- + - uses: actions/checkout@v6 with: # when called from a member repo, check out the hub (not the caller) diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js index a89ea06..8e49354 100644 --- a/docs/assets/js/pymm.js +++ b/docs/assets/js/pymm.js @@ -129,12 +129,28 @@ { id: "napari", label: "napari plugin", hint: "napari-micromanager", group: "gui" }, ]; + // What each package already brings in (from their pyproject dependencies): + // pymmcore-gui -> pymmcore-plus[cli], pymmcore-widgets, ome-writers (= [io]), PyQt6 + // napari-micromanager -> pymmcore-widgets -> pymmcore-plus[cli] + function implied(opts) { + return { + widgets: opts.gui || opts.napari, + io: opts.gui, + qt: opts.gui ? "PyQt6" : null, + }; + } + + // The fewest packages that install everything selected. function packages(opts) { - const qt = opts.qt; - const pkgs = [`"pymmcore-plus[${opts.io ? "cli,io" : "cli"}]"`]; - if (opts.widgets) pkgs.push(`"pymmcore-widgets[${qt}]"`); + const qt = implied(opts).qt || opts.qt; + const pkgs = []; if (opts.gui) pkgs.push("git+https://github.com/pymmcore-plus/pymmcore-gui"); - if (opts.napari) pkgs.push(`"napari-micromanager[${qt.toLowerCase()}]"`); + if (opts.napari) { + pkgs.push(opts.gui ? "napari-micromanager" : `"napari-micromanager[${qt.toLowerCase()}]"`); + } + if (opts.widgets && !opts.gui && !opts.napari) pkgs.push(`"pymmcore-widgets[${qt}]"`); + if (!pkgs.length) pkgs.push(`"pymmcore-plus[${opts.io ? "cli,io" : "cli"}]"`); + else if (opts.io && !opts.gui) pkgs.push(`"pymmcore-plus[io]"`); return pkgs; } @@ -154,20 +170,40 @@

Qt backend +
`; root.prepend(form); + const input = (name) => form.querySelector(`input[name="${name}"]`); + const radios = [...form.querySelectorAll('input[name="qt"]')]; + // remember what the user chose, so implied options can be restored + const chosen = { io: false, widgets: false, gui: false, napari: false, qt: "PyQt6" }; + const [pip, uv] = root.querySelectorAll(".tabbed-block pre code"); - const blocks = { pip, uv }; - function update() { - const data = new FormData(form); - const opts = { qt: data.get("qt") }; - for (const o of INSTALL_OPTIONS) opts[o.id] = data.has(o.id); - const anyQt = opts.widgets || opts.gui || opts.napari; - form.querySelectorAll('input[name="qt"]').forEach((el) => (el.disabled = !anyQt)); - const pkgs = packages(opts).join(" "); - if (blocks.pip) blocks.pip.textContent = `pip install ${pkgs}\nmmcore install # Micro-Manager device adapters`; - if (blocks.uv) blocks.uv.textContent = `uv add ${pkgs}\nuv run mmcore install # Micro-Manager device adapters`; + function update(event) { + const target = event?.target; + if (target?.name === "qt") chosen.qt = target.value; + else if (target?.name in chosen) chosen[target.name] = target.checked; + + const imp = implied(chosen); + for (const name of ["io", "widgets"]) { + input(name).disabled = Boolean(imp[name]); + input(name).checked = Boolean(imp[name]) || chosen[name]; + input(name).closest("label").title = imp[name] ? "included with your selection" : ""; + } + const anyQt = chosen.widgets || chosen.gui || chosen.napari; + for (const radio of radios) { + radio.disabled = !anyQt || Boolean(imp.qt); + radio.checked = radio.value === (imp.qt || chosen.qt); + } + form.querySelector(".pymm-installer-note").textContent = imp.qt + ? "pymmcore-gui uses PyQt6" + : ""; + + const pkgs = packages(chosen).join(" "); + const adapters = "mmcore install # Micro-Manager device adapters"; + if (pip) pip.textContent = `pip install ${pkgs}\n${adapters}`; + if (uv) uv.textContent = `uv add ${pkgs}\nuv run ${adapters}`; } form.addEventListener("change", update); update(); diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 65ae7cf..d1a246c 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -41,6 +41,11 @@ Pick what you need and copy the command: | The pymmcore-gui application | `pip install git+https://github.com/pymmcore-plus/pymmcore-gui` | | The napari plugin | `pip install "napari-micromanager[pyqt6]"` | + Each package brings in everything it builds on, so you only need the + top-most one: `pymmcore-gui` already includes `pymmcore-widgets`, + `pymmcore-plus[cli,io]` and PyQt6; `napari-micromanager` and + `pymmcore-widgets` include `pymmcore-plus[cli]`. + Packages are on [PyPI](https://pypi.org/user/tlambert03/) and [conda-forge](https://anaconda.org/conda-forge/pymmcore-plus). Python 3.10 or newer is required. From 106497cd26fb40cec84aee6224a7f1c6b52f9207 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 22:42:11 +0100 Subject: [PATCH 03/15] ci: software OpenGL on Windows for vispy widget screenshots Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4737228..acaec4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,12 @@ jobs: - run: uv sync - uses: pymmcore-plus/setup-mm-test-adapters@main + # software OpenGL (Mesa) on Windows, for widgets with a vispy canvas + # (no-op on macOS; Xvfb and Qt libraries on Linux) + - uses: pyvista/setup-headless-display-action@v5 + with: + qt: true + pyvista: false - name: Build env: From 98f4ae19537a44b7359cd9e585c6eb5b2fc7eb96 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:04:39 +0100 Subject: [PATCH 04/15] feat: widget screenshots follow the light/dark theme Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/assets/css/pymm.css | 7 +++++++ zensical.base.toml | 2 ++ 2 files changed, 9 insertions(+) diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index f5c11d4..5c12478 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -372,6 +372,13 @@ object-fit: contain; } +/* screenshots come in light and dark variants: show the one matching the theme + (the theme does this too, but the display rules above would override it) */ +[data-md-color-scheme="default"] .md-typeset img[src$="#only-dark"], +[data-md-color-scheme="slate"] .md-typeset img[src$="#only-light"] { + display: none; +} + /* --------------------------------------------------------------------------- API docs tweaks ------------------------------------------------------------------------ */ diff --git a/zensical.base.toml b/zensical.base.toml index d455c45..fe33edc 100644 --- a/zensical.base.toml +++ b/zensical.base.toml @@ -138,6 +138,8 @@ custom_checkbox = true resolve_closest = true [project.plugins.glightbox] +# light/dark screenshot pairs (`#only-light` / `#only-dark`) form separate galleries +auto_themed = true [project.plugins.mkdocs-video] is_video = true From f916169cf8d7bcb86026d25f45b549aefd8bea4f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:08:24 +0100 Subject: [PATCH 05/15] ci: fix the pyconify cache path (actions/cache rejects '..') Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- .github/workflows/build.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index acaec4b..9fb5ba0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,14 +40,14 @@ jobs: shell: bash env: PYTHONUTF8: "1" - # icons fetched from the Iconify API by some widgets (via pyconify) - PYCONIFY_CACHE: ${{ github.workspace }}/../pyconify-cache steps: - run: echo "T0=$SECONDS" >> "$GITHUB_ENV" + # icons that some widgets fetch from the Iconify API (via pyconify), which + # rate-limits CI runners - uses: actions/cache@v4 with: - path: ${{ github.workspace }}/../pyconify-cache + path: ${{ runner.temp }}/pyconify-cache key: pyconify-${{ runner.os }}-${{ github.run_id }} restore-keys: pyconify-${{ runner.os }}- @@ -80,6 +80,7 @@ jobs: # use the OS's native Qt platform, so widget screenshots get its style QT_QPA_PLATFORM: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'macOS' && 'cocoa' || 'offscreen' }} SCOPE: ${{ inputs.member || 'hub' }} + PYCONIFY_CACHE: ${{ runner.temp }}/pyconify-cache run: | echo "T1=$SECONDS" >> "$GITHUB_ENV" uv run tools/compose.py build --strict --scope "$SCOPE" From 53b2caa9770bdd0aae3225b983c15a8498415e79 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:13:49 +0100 Subject: [PATCH 06/15] ci: temporarily probe the Iconify API from the runners Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9fb5ba0..c2b5de2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,6 +75,14 @@ jobs: qt: true pyvista: false + - name: Probe Iconify API (temporary) + continue-on-error: true + run: | + for q in "mdi/chevron-double-up.svg?color=green" "mdi/chevron-up.svg" "version"; do + echo "== $q" + curl -sS -D - -o /dev/null -A pyconify "https://api.iconify.design/$q" | head -20 + done + - name: Build env: # use the OS's native Qt platform, so widget screenshots get its style From f50cb51df4288ba26e31f4cff60d5e91fc1cf0a6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:19:17 +0100 Subject: [PATCH 07/15] ci: remove the Iconify probe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- .github/workflows/build.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2b5de2..9fb5ba0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,14 +75,6 @@ jobs: qt: true pyvista: false - - name: Probe Iconify API (temporary) - continue-on-error: true - run: | - for q in "mdi/chevron-double-up.svg?color=green" "mdi/chevron-up.svg" "version"; do - echo "== $q" - curl -sS -D - -o /dev/null -A pyconify "https://api.iconify.design/$q" | head -20 - done - - name: Build env: # use the OS's native Qt platform, so widget screenshots get its style From bf363151bc73619389990e0870508ecf40f11d30 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:34:22 +0100 Subject: [PATCH 08/15] feat: collapse API backlinks into a one-line summary with counts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/assets/css/pymm.css | 43 ++++++++++++++++++++++++++++++++++++++++ docs/assets/js/pymm.js | 28 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index 5c12478..945655e 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -383,6 +383,49 @@ API docs tweaks ------------------------------------------------------------------------ */ +/* backlinks, collapsed into one line by pymm.js (not a themed admonition) */ +.md-typeset details.pymm-backlinks { + margin: 0.4em 0 0.8em; + padding: 0; + border: none; + box-shadow: none; + background: none; + font-size: 0.68rem; +} +.md-typeset details.pymm-backlinks > summary { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin: 0; + padding: 0.1em 0.6em 0.1em 1.4em; + border-radius: 1em; + background: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color--light); + font-weight: normal; +} +.md-typeset details.pymm-backlinks > summary:hover { + color: var(--md-accent-fg-color); +} +.md-typeset details.pymm-backlinks > summary::before { + display: none; +} +.md-typeset details.pymm-backlinks > summary::after { + top: 0.2em; + left: 0.35em; + right: auto; + width: 0.9em; + height: 0.9em; +} +.pymm-backlinks__count { + font-weight: 700; +} +.pymm-backlinks__sep { + opacity: 0.5; +} +.md-typeset details.pymm-backlinks > .doc-backlinks { + margin-top: 0.4em; +} + /* indent members under their parent object */ .md-typeset .doc-contents:not(.first) { padding-left: 1.2em; diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js index 8e49354..c829c36 100644 --- a/docs/assets/js/pymm.js +++ b/docs/assets/js/pymm.js @@ -213,11 +213,39 @@ return ``; } + // ------------------------------------------------------------------------- + // Backlinks (mkdocstrings "Referenced by", "Used by", ...): collapse each + // block into one summary line with counts, e.g. "Used by 2 · Referenced by 31" + // ------------------------------------------------------------------------- + + function collapseBacklinks(block) { + if (block.parentElement.matches("details.pymm-backlinks")) return; + const parts = [...block.querySelectorAll(":scope > .doc-backlink-type")].map((type) => { + const list = type.nextElementSibling; + // tree mode nests entries under their page: count the leaves + const leaves = [...list.querySelectorAll("li.doc-backlink")].filter( + (li) => !li.querySelector("ul"), + ); + return { label: type.textContent.replace(/:\s*$/, ""), n: leaves.length }; + }); + // the specific relations first; "Referenced by" (any mention) last + parts.sort((a, b) => (a.label === "Referenced by") - (b.label === "Referenced by")); + const details = document.createElement("details"); + details.className = "pymm-backlinks"; + const summary = document.createElement("summary"); + summary.innerHTML = parts + .map((p) => `${p.label} ${p.n}`) + .join('·'); + block.replaceWith(details); + details.append(summary, block); + } + // ------------------------------------------------------------------------- function init() { document.querySelectorAll("table.pymm-explorer").forEach(initExplorer); document.querySelectorAll("[data-pymm-installer]").forEach(initInstaller); + document.querySelectorAll(".doc-backlinks").forEach(collapseBacklinks); } if (typeof document$ !== "undefined") { From 2a4be05e1241a2cd8e6b91d9208e0263b96b15b3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:36:43 +0100 Subject: [PATCH 09/15] fix: backlinks summary styling (inline chevron, grouped counts) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/assets/css/pymm.css | 52 +++++++++++++++++++++++++++------------- docs/assets/js/pymm.js | 12 +++++++--- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index 945655e..cc8028c 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -394,33 +394,51 @@ } .md-typeset details.pymm-backlinks > summary { display: inline-flex; + flex-wrap: wrap; align-items: center; - gap: 0.4em; margin: 0; - padding: 0.1em 0.6em 0.1em 1.4em; - border-radius: 1em; - background: var(--md-default-fg-color--lightest); + padding: 0; + background: none; color: var(--md-default-fg-color--light); font-weight: normal; + line-height: 1.6; + list-style: none; + cursor: pointer; } -.md-typeset details.pymm-backlinks > summary:hover { - color: var(--md-accent-fg-color); -} -.md-typeset details.pymm-backlinks > summary::before { +.md-typeset details.pymm-backlinks > summary::-webkit-details-marker { display: none; } +/* the theme's admonition icon and chevron */ +.md-typeset details.pymm-backlinks > summary::before, .md-typeset details.pymm-backlinks > summary::after { - top: 0.2em; - left: 0.35em; - right: auto; - width: 0.9em; - height: 0.9em; + display: none; } -.pymm-backlinks__count { - font-weight: 700; +.md-typeset details.pymm-backlinks > summary:hover { + color: var(--md-accent-fg-color); +} +.pymm-backlinks__chevron { + flex: none; + width: 1.1em; + height: 1.1em; + margin-right: 0.25em; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 0.15s; +} +details.pymm-backlinks[open] > summary .pymm-backlinks__chevron { + transform: rotate(90deg); +} +.pymm-backlinks__item b { + color: var(--md-default-fg-color); + font-weight: 600; } -.pymm-backlinks__sep { - opacity: 0.5; +.pymm-backlinks__item + .pymm-backlinks__item::before { + content: "·"; + margin: 0 0.6em; + opacity: 0.6; } .md-typeset details.pymm-backlinks > .doc-backlinks { margin-top: 0.4em; diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js index c829c36..4f4d581 100644 --- a/docs/assets/js/pymm.js +++ b/docs/assets/js/pymm.js @@ -218,6 +218,10 @@ // block into one summary line with counts, e.g. "Used by 2 · Referenced by 31" // ------------------------------------------------------------------------- + const CHEVRON = + ''; + function collapseBacklinks(block) { if (block.parentElement.matches("details.pymm-backlinks")) return; const parts = [...block.querySelectorAll(":scope > .doc-backlink-type")].map((type) => { @@ -233,9 +237,11 @@ const details = document.createElement("details"); details.className = "pymm-backlinks"; const summary = document.createElement("summary"); - summary.innerHTML = parts - .map((p) => `${p.label} ${p.n}`) - .join('·'); + summary.innerHTML = + CHEVRON + + parts + .map((p) => `${p.label} ${p.n}`) + .join(""); block.replaceWith(details); details.append(summary, block); } From 986de50f331e6fcd7af5cb7aa4381ccb5217dec4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 26 Oct 2026 23:43:47 +0100 Subject: [PATCH 10/15] docs: useq schema pages move to the API Reference Describing experiments now holds the useq overview and the v2 migration guide; links that pointed at the schema pages as a guide are updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/.nav.yml | 5 +---- docs/api/index.md | 2 +- docs/getting-started/first-acquisition.md | 2 +- docs/getting-started/index.md | 2 +- docs/guide/index.md | 5 +++-- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/.nav.yml b/docs/.nav.yml index 4c05810..bb8a685 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -19,10 +19,7 @@ nav: - pymmcore-plus/examples/following_changes_in_core.md - pymmcore-plus/examples/context-set.md - Describing experiments: - - useq-schema/schema/sequence.md - - useq-schema/schema/event.md - - useq-schema/schema/axes.md - - useq-schema/schema/hardware_autofocus.md + - useq-schema/index.md - useq-schema/v2-migration.md - Running acquisitions: - pymmcore-plus/guides/mda_engine.md diff --git a/docs/api/index.md b/docs/api/index.md index 1618a8b..5ed3a5f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -20,7 +20,7 @@ other modules that re-export it link there. device objects, constants, metadata, and the [`mmcore` CLI](../pymmcore-plus/cli.md). -- **[useq-schema](../useq-schema/index.md)** +- **[useq-schema](../useq-schema/schema/sequence.md)** --- diff --git a/docs/getting-started/first-acquisition.md b/docs/getting-started/first-acquisition.md index c6e0027..4758acd 100644 --- a/docs/getting-started/first-acquisition.md +++ b/docs/getting-started/first-acquisition.md @@ -42,7 +42,7 @@ len(list(sequence)) # 60 events Iterating the sequence yields [`MDAEvent`][useq.MDAEvent] objects — one per image — each saying exactly what the hardware should do. See -[Describing experiments](../useq-schema/schema/sequence.md) for everything a +the [`MDASequence` reference](../useq-schema/schema/sequence.md) for everything a sequence can express (grids, well plates, autofocus, per-position sub-sequences, ...). diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index b60af8e..ae97ffe 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -13,7 +13,7 @@ Most people need just one or two of them: | Control a microscope from inside [napari](https://napari.org) | **napari-micromanager** | [napari-micromanager](../napari-micromanager/index.md) | | Script your microscope from Python or Jupyter | **pymmcore-plus** | [First steps](first-steps.md) | | Run multi-dimensional acquisitions from code | **pymmcore-plus** + **useq-schema** | [Your first acquisition](first-acquisition.md) | -| Describe experiments in a hardware-agnostic way | **useq-schema** | [Describing experiments](../useq-schema/schema/sequence.md) | +| Describe experiments in a hardware-agnostic way | **useq-schema** | [Describing experiments](../useq-schema/index.md) | | Build your own Qt GUI for a microscope | **pymmcore-widgets** | [Your first GUI](first-gui.md) | ``` mermaid diff --git a/docs/guide/index.md b/docs/guide/index.md index eff64f9..cb18d4b 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -25,9 +25,10 @@ New here? Start with [Getting Started](../getting-started/index.md). --- - `useq-schema`: sequences, events, time/z/grid/position plans, autofocus. + `useq-schema`: an acquisition as a sequence of events (time, z, channels, + positions, grids, autofocus). - [:octicons-arrow-right-24: Sequences](../useq-schema/schema/sequence.md) + [:octicons-arrow-right-24: Sequences and events](../useq-schema/index.md) - :lucide-play:{ .lg .middle } **Running acquisitions** From 1e683d746d4dccf5aee918f61f8509e954cd425a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 Oct 2026 06:58:34 +0100 Subject: [PATCH 11/15] docs: review pass on hub pages, structure and API page layout - new pages: Coming from Micro-Manager, Using Your Own Microscope; Getting Started overview renamed to which-package.md so it shows in the sidebar - nav: Building Sequences and Saving Data added; Title Case labels - API module pages: summary tables, one block per object, re-export-only modules dropped, private/deprecated/logger names filtered, edit links point at the module source - griffe: TidyDocs extension (dataclass/builtin docstring noise, ForwardRef, undocumented Qt instance attributes); griffe-fieldz adds to Attributes - CSS/JS: method table fits its column, enum grid, backlinks summary, mobile hero, contrast fixes; llms.txt sections mirror the nav - redirects for the folded pymmcore-plus example pages Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- compose.toml | 2 +- docs/.nav.yml | 48 +++--- docs/api/index.md | 40 +++-- docs/assets/css/pymm.css | 88 ++++++++++- docs/assets/js/pymm.js | 42 ++++- docs/contributing/docs.md | 18 ++- docs/contributing/index.md | 29 ++-- docs/getting-started/first-acquisition.md | 173 +++++++++++++-------- docs/getting-started/first-gui.md | 84 +++++----- docs/getting-started/first-steps.md | 116 ++++++++++---- docs/getting-started/from-micro-manager.md | 130 ++++++++++++++++ docs/getting-started/index.md | 62 -------- docs/getting-started/install.md | 115 ++++++++++---- docs/getting-started/which-package.md | 61 ++++++++ docs/guide/index.md | 65 ++++---- docs/guide/own-microscope.md | 157 +++++++++++++++++++ docs/index.md | 104 +++++-------- overrides/main.html | 5 +- overrides/partials/content.html | 2 + redirects.toml | 6 +- tools/api.py | 141 +++++++++++++---- tools/compose.py | 18 ++- tools/pymm_griffe.py | 95 +++++++++++ tools/pymm_mdx.py | 34 +++- zensical.base.toml | 43 ++++- 25 files changed, 1252 insertions(+), 426 deletions(-) create mode 100644 docs/getting-started/from-micro-manager.md delete mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/which-package.md create mode 100644 docs/guide/own-microscope.md create mode 100644 tools/pymm_griffe.py diff --git a/compose.toml b/compose.toml index 92c1125..9dfc487 100644 --- a/compose.toml +++ b/compose.toml @@ -27,7 +27,7 @@ pypi = "pymmcore-widgets" name = "pymmcore-gui" repo = "pymmcore-plus/pymmcore-gui" title = "pymmcore-gui" -pypi = "pymmcore-gui" +pypi = "" # installed from GitHub for now; the PyPI release is an old pre-release [[member]] name = "napari-micromanager" diff --git a/docs/.nav.yml b/docs/.nav.yml index bb8a685..7d3e4ea 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -1,55 +1,57 @@ # Site-wide navigation (awesome-nav). Member pages are placed here by path; -# each member's own docs/.nav.yml orders its API Reference section. +# each member's own docs/.nav.yml orders its API Reference section (for the +# applications, pymmcore-gui and napari-micromanager, its pages). nav: - Home: index.md - Getting Started: - - getting-started/index.md + - getting-started/which-package.md - getting-started/install.md - - Micro-Manager device adapters: pymmcore-plus/install.md - getting-started/first-steps.md - getting-started/first-acquisition.md - getting-started/first-gui.md + - getting-started/from-micro-manager.md - User Guide: - guide/index.md - - Controlling hardware: + - Controlling Hardware: + - Micro-Manager Device Adapters: pymmcore-plus/install.md + - guide/own-microscope.md - pymmcore-plus/guides/devices.md - pymmcore-plus/guides/configuration.md - - pymmcore-plus/guides/constants.md - pymmcore-plus/guides/events.md - - pymmcore-plus/examples/following_changes_in_core.md - - pymmcore-plus/examples/context-set.md - - Describing experiments: + - pymmcore-plus/guides/ipython_completion.md + - Describing Experiments: - useq-schema/index.md - - useq-schema/v2-migration.md - - Running acquisitions: + - useq-schema/guides/sequences.md + - Running Acquisitions: - pymmcore-plus/guides/mda_engine.md - - pymmcore-plus/examples/mda.md + - pymmcore-plus/guides/saving_data.md - pymmcore-plus/guides/event_driven_acquisition.md - pymmcore-plus/guides/custom_engine.md - Building GUIs: - - pymmcore-widgets/getting_started.md - - Widget gallery: pymmcore-widgets/widgets/index.md - - pymmcore-plus/examples/integration-with-qt.md + - pymmcore-widgets/widgets/index.md + - Building a Custom GUI: pymmcore-widgets/getting_started.md + - Qt Integration: pymmcore-plus/examples/integration-with-qt.md - Applications: - pymmcore-gui - napari-micromanager - - Advanced topics: + - Advanced Topics: - pymmcore-plus/guides/unicore.md - - pymmcore-plus/guides/logging.md - - pymmcore-plus/profiling.md - - pymmcore-plus/guides/ipython_completion.md + - useq-schema v2 (Preview): useq-schema/v2-migration.md - pymmcore-plus/examples/pycro-manager-api.md - Troubleshooting: - - pymmcore-plus/troubleshooting.md - - pymmcore-widgets/troubleshooting.md + - Core and Device Adapters: pymmcore-plus/troubleshooting.md + - Qt and Widgets: pymmcore-widgets/troubleshooting.md + - pymmcore-plus/guides/logging.md + - pymmcore-plus/profiling.md - API Reference: - api/index.md - pymmcore-plus - useq-schema - pymmcore-widgets - - pymmcore-gui: pymmcore-gui/api/pymmcore_gui + - pymmcore-gui: + - pymmcore-gui/api/pymmcore_gui - Contributing: - contributing/index.md - contributing/docs.md - - pymmcore-plus/contributing.md - - pymmcore-widgets/contributing.md + - Developing pymmcore-plus: pymmcore-plus/contributing.md + - Developing pymmcore-widgets: pymmcore-widgets/contributing.md diff --git a/docs/api/index.md b/docs/api/index.md index 5ed3a5f..09b4641 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,10 +4,10 @@ title: API Reference # API Reference -The API reference is generated from the source code of each package. Every -public object is documented once, under its **shortest public import path** -(e.g. `pymmcore_plus.CMMCorePlus`, not `pymmcore_plus.core.CMMCorePlus`); -other modules that re-export it link there. +These pages are generated from the docstrings of each package. Each object is +documented once, under the shortest path you can import it from (e.g. +`pymmcore_plus.CMMCorePlus`, not `pymmcore_plus.core.CMMCorePlus`). Other modules +that re-export it just link there.
@@ -15,36 +15,48 @@ other modules that re-export it link there. --- - [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] (every core method, with what - pymmcore-plus adds), the [acquisition engine][pymmcore_plus.mda], - device objects, constants, metadata, and the - [`mmcore` CLI](../pymmcore-plus/cli.md). + [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] (all of the core's methods, + marking what pymmcore-plus adds or changes), the + [acquisition engine][pymmcore_plus.mda], device objects, constants, metadata, + and the [`mmcore` command line tool](../pymmcore-plus/cli.md). - **[useq-schema](../useq-schema/schema/sequence.md)** --- - [`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent], and all - axis plans. + [`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent], and the + plans for each axis (time, z, positions, grids, autofocus). -- **[pymmcore-widgets](../pymmcore-widgets/index.md)** +- **[pymmcore-widgets](../pymmcore-widgets/api/pymmcore_widgets/index.md)** --- - Every widget, with a screenshot and an example. + One page for each widget, with a screenshot and an example. (The + [widget gallery](../pymmcore-widgets/widgets/index.md) is a quicker way to + browse them.) - **[pymmcore-gui](../pymmcore-gui/api/pymmcore_gui/index.md)** --- - Application entry points and extension points (actions, widgets). + The application's entry points ([`create_mmgui`][pymmcore_gui.create_mmgui]) + and the actions and widgets you can use to extend it.
+napari-micromanager has no public Python API of its own, but you can drive it from +a script (see [Scripting napari-micromanager](../napari-micromanager/scripting.md)). + +Release notes for each package are on GitHub: +[pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus/releases), +[useq-schema](https://github.com/pymmcore-plus/useq-schema/releases), +[pymmcore-widgets](https://github.com/pymmcore-plus/pymmcore-widgets/releases), +[napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager/releases). + !!! tip "Linking to these docs from your own project" Each package publishes an object inventory for [mkdocstrings](https://mkdocstrings.github.io/) and [intersphinx](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html), e.g. `https://pymmcore-plus.github.io/pymmcore-plus/objects.inv` (or - `/objects.inv` for everything). + `https://pymmcore-plus.github.io/objects.inv` for everything). diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index cc8028c..40e0a2e 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -65,10 +65,16 @@ @media screen and (max-width: 60em) { .pymm-hero { - grid-template-columns: 1fr; + /* minmax(0, ...): don't let the code block widen the page on phones */ + grid-template-columns: minmax(0, 1fr); } } +/* no edit/view/copy buttons next to the landing page's title */ +.md-content__inner:has(.pymm-hero) > .md-content__button { + display: none; +} + /* Layered "how the pieces fit together" diagram */ .pymm-stack { display: flex; @@ -185,16 +191,28 @@ width: 100%; } +.md-typeset table.pymm-explorer th { + text-align: left; +} + .md-typeset table.pymm-explorer td { padding: 0.35em 0.8em; vertical-align: top; } -.md-typeset table.pymm-explorer td:first-child, -.md-typeset table.pymm-explorer td:nth-child(3) { +.md-typeset table.pymm-explorer td:first-child { white-space: nowrap; } +.md-typeset table.pymm-explorer td:nth-child(3) { + min-width: 6.5rem; +} + +/* long code spans in "Why override?" notes must not widen the table */ +.md-typeset table.pymm-explorer td:last-child { + overflow-wrap: anywhere; +} + .md-typeset table.pymm-explorer td:nth-child(3) a { color: var(--md-default-fg-color--light); } @@ -215,7 +233,7 @@ display: block; font-family: var(--md-code-font-family); font-size: 0.85em; - color: var(--md-default-fg-color--lighter); + color: var(--md-default-fg-color--light); white-space: normal; max-width: 22rem; } @@ -229,10 +247,11 @@ color: var(--pymm-overridden); } -/* Toolbar built by pymm.js */ +/* Toolbar built by pymm.js (inside .pymm-explorer-wrap, so it only sticks while + the table is on screen); below the header and the sticky tabs */ .pymm-explorer-toolbar { position: sticky; - top: 2.4rem; + top: 4.8rem; z-index: 2; display: flex; flex-wrap: wrap; @@ -281,7 +300,14 @@ .pymm-explorer-count { margin-left: auto; - color: var(--md-default-fg-color--lighter); + color: var(--md-default-fg-color--light); +} + +/* the tabs aren't shown (or sticky) on narrower screens */ +@media screen and (max-width: 76.234375em) { + .pymm-explorer-toolbar { + top: 2.4rem; + } } /* Origin badge added to member headings by pymm.js */ @@ -454,3 +480,51 @@ details.pymm-backlinks[open] > summary .pymm-backlinks__chevron { .md-nav .doc-symbol-nav { margin-right: 0.3em; } + +/* --------------------------------------------------------------------------- + API pages: quieter headings, enums, tables + ------------------------------------------------------------------------ */ + +/* the kind of object ("class", "method", ...) is a small tag, not half the title */ +.md-typeset .doc-heading > .doc-symbol-heading { + font-size: 0.6em; + vertical-align: 0.2em; + font-weight: 600; +} + +/* the theme's module green is too light on white (2.8:1) */ +[data-md-color-scheme="default"] { + --doc-symbol-module-fg-color: #3f7d0b; + --doc-symbol-module-bg-color: #3f7d0b1a; +} + +/* enum members (tagged by pymm.js) as a compact grid of names; anchors stay */ +.md-typeset .pymm-enum .doc-children { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 0 1rem; +} +.md-typeset .pymm-enum .doc-children > .doc-attribute > .doc-heading { + font-size: 0.7rem; + margin: 0.1em 0; +} +.md-typeset .pymm-enum .doc-children > .doc-attribute :is( + .doc-symbol-heading, + .doc-labels, + .doc-signature, + .pymm-backlinks, + .doc-backlinks, + .headerlink + ) { + display: none; +} +.md-typeset .pymm-enum .doc-children > .doc-attribute > .doc-contents { + padding-left: 0; + border-left: none; +} + +/* names in the first column of reference tables shouldn't break mid-word */ +.md-typeset td:first-child > code, +.md-typeset td:first-child > a > code { + white-space: nowrap; +} diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js index 4f4d581..65b5f55 100644 --- a/docs/assets/js/pymm.js +++ b/docs/assets/js/pymm.js @@ -5,7 +5,7 @@ "use strict"; const ORIGINS = { - added: { label: "✨ Added", badge: "pymmcore-plus" }, + added: { label: "✨ Added", badge: "added" }, overridden: { label: "➕ Overridden", badge: "overridden" }, inherited: { label: "Inherited", badge: null }, }; @@ -78,7 +78,20 @@ status.className = "pymm-explorer-count"; toolbar.append(status); - (table.closest(".md-typeset__scrollwrap") || table).before(toolbar); + // the theme only makes tables without a class scrollable: do it here, so a + // narrow screen scrolls the table rather than the whole page + let block = table.closest(".md-typeset__scrollwrap"); + if (!block) { + block = Object.assign(document.createElement("div"), { className: "md-typeset__scrollwrap" }); + const inner = Object.assign(document.createElement("div"), { className: "md-typeset__table" }); + table.replaceWith(block); + inner.append(table); + block.append(inner); + } + // one container for both, so the sticky toolbar stops where the table ends + const wrap = Object.assign(document.createElement("div"), { className: "pymm-explorer-wrap" }); + block.replaceWith(wrap); + wrap.append(toolbar, block); function apply() { let shown = 0; @@ -123,7 +136,7 @@ // ------------------------------------------------------------------------- const INSTALL_OPTIONS = [ - { id: "io", label: "Save to OME-Zarr / OME-TIFF", hint: "pymmcore-plus[io]", group: "core" }, + { id: "io", label: "Save to OME-TIFF (OME-Zarr works without it)", hint: "pymmcore-plus[io]", group: "core" }, { id: "widgets", label: "Qt widgets", hint: "pymmcore-widgets", group: "gui" }, { id: "gui", label: "pymmcore-gui app", hint: "from GitHub", group: "gui" }, { id: "napari", label: "napari plugin", hint: "napari-micromanager", group: "gui" }, @@ -146,7 +159,7 @@ const pkgs = []; if (opts.gui) pkgs.push("git+https://github.com/pymmcore-plus/pymmcore-gui"); if (opts.napari) { - pkgs.push(opts.gui ? "napari-micromanager" : `"napari-micromanager[${qt.toLowerCase()}]"`); + pkgs.push(opts.gui ? "napari-micromanager" : `"napari-micromanager[${qt}]"`); } if (opts.widgets && !opts.gui && !opts.napari) pkgs.push(`"pymmcore-widgets[${qt}]"`); if (!pkgs.length) pkgs.push(`"pymmcore-plus[${opts.io ? "cli,io" : "cli"}]"`); @@ -232,7 +245,12 @@ ); return { label: type.textContent.replace(/:\s*$/, ""), n: leaves.length }; }); - // the specific relations first; "Referenced by" (any mention) last + // a lone "Referenced by" (any mention, often from the same page) isn't worth a line + if (parts.every((p) => p.label === "Referenced by")) { + block.remove(); + return; + } + // the specific relations first; "Referenced by" last parts.sort((a, b) => (a.label === "Referenced by") - (b.label === "Referenced by")); const details = document.createElement("details"); details.className = "pymm-backlinks"; @@ -248,10 +266,24 @@ // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Enums: their members are one-line constants; show them as a compact grid + // (pymm.css) instead of one full heading each. The anchors stay. + // ------------------------------------------------------------------------- + + function tagEnum(bases) { + if (/\b(Int|Str)?(Enum|Flag)\b/.test(bases.textContent)) { + bases.closest(".doc-class")?.classList.add("pymm-enum"); + } + } + + // ------------------------------------------------------------------------- + function init() { document.querySelectorAll("table.pymm-explorer").forEach(initExplorer); document.querySelectorAll("[data-pymm-installer]").forEach(initInstaller); document.querySelectorAll(".doc-backlinks").forEach(collapseBacklinks); + document.querySelectorAll(".doc-class-bases").forEach(tagEnum); } if (typeof document$ !== "undefined") { diff --git a/docs/contributing/docs.md b/docs/contributing/docs.md index fe18be1..51e97ec 100644 --- a/docs/contributing/docs.md +++ b/docs/contributing/docs.md @@ -1,12 +1,12 @@ --- -title: How these docs work +title: How These Docs Work --- -# How these docs work +# How These Docs Work -This site combines documentation from several repositories into a single site, -with one navigation, one search, and cross-references between all packages. It -is built with [zensical](https://zensical.org) by +This site is put together from the `docs/` folders (and docstrings) of several +repositories, so that all of the packages share one navigation and search, and can +link to each other. It's built with [zensical](https://zensical.org) by [pymmcore-plus/pymmcore-plus.github.io](https://github.com/pymmcore-plus/pymmcore-plus.github.io) (the *hub*). @@ -29,7 +29,7 @@ graph LR **Each member repository** owns: -- `docs/`: its pages, written exactly as for a stand-alone site. The folder is +- `docs/`: its pages, written as they would be for a stand-alone site. The folder is published at `//`, so `pymmcore-plus/docs/guides/events.md` becomes `/pymmcore-plus/guides/events/`. - `docs/**/.nav.yml`: [awesome-nav](https://lukasgeiter.github.io/mkdocs-awesome-nav/) @@ -42,8 +42,10 @@ graph LR screenshots). **The hub** owns the theme, global settings, the landing page, *Getting -Started*, and the top-level navigation (`docs/.nav.yml`), which places member -pages into the site-wide *User Guide*. +Started* (plus a few pages that don't belong to any one package, like +[Using your own microscope](../guide/own-microscope.md)), and the top-level +navigation (`docs/.nav.yml`), which places member pages into the site-wide +*User Guide*. ## Previewing locally diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 7a4c5f5..d6cb346 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -4,9 +4,10 @@ title: Contributing # Contributing -Thanks for thinking of a way to help! Contributions come in many shapes: -reporting bugs, asking questions where something is unclear, improving -documentation, and writing code are all valuable. +Thanks for thinking of a way to help! Remember that contributions come in all +shapes and sizes beyond writing bug fixes. Improving documentation, opening issues +for bugs, asking for clarification on things you find unclear, and requesting new +features are all super valuable contributions. ## Where things live @@ -21,29 +22,33 @@ Each package has its own repository, issue tracker, and contributing notes: | napari-micromanager | [pymmcore-plus/napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager) | | | this website | [pymmcore-plus/pymmcore-plus.github.io](https://github.com/pymmcore-plus/pymmcore-plus.github.io) | [How these docs work](docs.md) | -Not sure where something belongs? Ask on the +Not sure where something belongs? Open it wherever seems closest (we can move +issues between repositories), or ask us on the [image.sc forum](https://forum.image.sc/tag/pymmcore-plus) or [Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D). ## Development setup -All repositories use [uv](https://docs.astral.sh/uv/) and -[prek](https://github.com/j178/prek) (or pre-commit): +We use [uv](https://docs.astral.sh/uv/) in all of the repositories (but any +virtual environment manager should work), and [prek](https://github.com/j178/prek) +(or pre-commit) for linting: ```sh -git clone https://github.com/pymmcore-plus/ +git clone cd uv sync -prek install +uv run prek install # or: uv run pre-commit install uv run pytest ``` -Most tests need the Micro-Manager demo device adapters: `uv run mmcore install`. +Most of the tests need Micro-Manager's demo device adapters, so run +`uv run mmcore install` once if you haven't already. ## Improving the docs -Every page has an :material-file-edit-outline: **edit** button (top right) that -opens the source file in the right repository. Each package's docs live in its -own `docs/` folder (and in its docstrings), so a pull request that changes code +Most pages have an :material-file-edit-outline: edit button (top right) that +opens the source file in the right repository. (Pages in the API reference are +generated from docstrings, so edit those in the package's `src/` folder.) Each +package's docs live in its own `docs/` folder, so a pull request that changes code can update the documentation at the same time. To preview the full site locally, see [How these docs work](docs.md). diff --git a/docs/getting-started/first-acquisition.md b/docs/getting-started/first-acquisition.md index 4758acd..34a6862 100644 --- a/docs/getting-started/first-acquisition.md +++ b/docs/getting-started/first-acquisition.md @@ -1,99 +1,146 @@ --- -title: Your first acquisition +title: Your First Acquisition --- -# Your first acquisition +# Your First Acquisition -A *multi-dimensional acquisition* (MDA) is a series of images collected across -time points, stage positions, channels, z-planes, and so on. It takes two -pieces: +A multi-dimensional acquisition (MDA) is a series of images collected across time +points, stage positions, channels, z-planes, etc... In pymmcore-plus, the +experiment is *described* with [useq-schema](../useq-schema/index.md) and *run* by +the [acquisition engine](../pymmcore-plus/guides/mda_engine.md). -- [**useq-schema**](../useq-schema/index.md) to **describe** the experiment, and -- the [**pymmcore-plus** acquisition engine](../pymmcore-plus/guides/mda_engine.md) - to **run** it. +We'll need a core with a configuration loaded: -## Describe the experiment +```python +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() +``` + +## Describing the experiment -An [`MDASequence`][useq.MDASequence] declares what to acquire. It is plain data: -you can save it as JSON or YAML, and it knows nothing about hardware. +An [`MDASequence`][useq.MDASequence] declares what to acquire. It doesn't know +anything about your hardware (beyond the names of things like channel presets), +and can be saved to (and loaded from) JSON or YAML. ```python import useq +x, y = core.getXYPosition() +z = core.getZPosition() + sequence = useq.MDASequence( channels=["DAPI", {"config": "FITC", "exposure": 50}], # (1)! - time_plan={"interval": 0.5, "loops": 3}, # (2)! - z_plan={"range": 4, "step": 1}, # (3)! - stage_positions=[(0, 0, 0), (100, 100, 0)], # (4)! - axis_order="tpcz", # (5)! + time_plan={"interval": 0.5, "loops": 3}, # (2)! + z_plan={"range": 4, "step": 1}, # (3)! + stage_positions=[(x, y, z), (x + 100, y, z)], # (4)! + axis_order="tpcz", # (5)! ) -sequence.sizes # {'t': 3, 'p': 2, 'c': 2, 'z': 5} -len(list(sequence)) # 60 events +sequence.sizes # {'t': 3, 'p': 2, 'c': 2, 'z': 5} +len(list(sequence)) # 60 events ``` -1. Channels are configuration presets in the `Channel` group (the default - group), with an optional exposure. -2. 3 time points, 0.5 s apart. -3. A 4 µm z-stack around the current position, in 1 µm steps. -4. Two `(x, y, z)` stage positions. -5. The nesting order: for each **t**ime point, visit each **p**osition, image each - **c**hannel, then each **z** plane. - -Iterating the sequence yields [`MDAEvent`][useq.MDAEvent] objects — one per -image — each saying exactly what the hardware should do. See -the [`MDASequence` reference](../useq-schema/schema/sequence.md) for everything a -sequence can express (grids, well plates, autofocus, per-position -sub-sequences, ...). - -## Run it +1. Channels are presets in the `Channel` configuration group (the default + group), with an optional exposure in milliseconds. +2. 3 time points, 0.5 seconds apart. +3. A 4 µm z-stack in 1 µm steps, centered on the `z` of each stage position. +4. Two `(x, y, z)` stage positions, in µm: the current position, and one + 100 µm away in x. These are absolute stage coordinates, so on a real + microscope, make sure they're somewhere safe! +5. The nesting order, outermost first: for each **t**ime point, visit each + **p**osition, and there, for each **c**hannel, take a **z**-stack. + +!!! warning "Where is the z-stack?" + + A z-stack described with `range` (or `above` and `below`) is *relative* to the + `z` of each stage position. If a position has no `z`, or the sequence has no + stage positions at all, it is currently relative to z = 0, *not* to wherever + your focus is. On a real microscope that could drive the objective a long + way, which is why we pass the current `z` explicitly above. + +Iterating over the sequence yields one [`MDAEvent`][useq.MDAEvent] per image, each +describing what the hardware should do for that image (any field that is left +unset means "leave it as it is"). See the +[`MDASequence` reference](../useq-schema/schema/sequence.md) for more of what a +sequence can express (grids, well plates, autofocus, per-position sub-sequences, +etc...). + +## Running it ```python -from pymmcore_plus import CMMCorePlus - -core = CMMCorePlus() -core.loadSystemConfiguration() - @core.mda.events.frameReady.connect # (1)! def on_frame(image, event, metadata): print(event.index, image.shape) -core.mda.run(sequence, output="experiment.ome.zarr") # (2)! +core.mda.run(sequence, output="experiment.ome.zarr", overwrite=True) # (2)! ``` 1. Connect to [events][pymmcore_plus.mda.events.PMDASignaler] to process data - as it arrives. -2. `mda.run` blocks until the acquisition is done. Use - [`core.run_mda(sequence)`][pymmcore_plus.CMMCorePlus.run_mda] to run on a - background thread instead. + as it arrives. `frameReady` is called with the image (a numpy array), the + `MDAEvent` and a dict of metadata. +2. `mda.run` blocks until the acquisition is done. (Use + [`core.run_mda(sequence)`][pymmcore_plus.CMMCorePlus.run_mda] to run it on a + background thread instead.) `overwrite=True` replaces `experiment.ome.zarr` + if it already exists; without it, running the script a second time raises a + `FileExistsError`. -The `output` argument saves to OME-Zarr (or `.ome.tiff`) using -[ome-writers](https://pymmcore-plus.github.io/ome-writers/); install it with -`pip install "pymmcore-plus[io]"`. +... prints a line for each of the 60 images: -## Beyond sequences +``` +{'t': 0, 'p': 0, 'c': 0, 'z': 0} (512, 512) +{'t': 0, 'p': 0, 'c': 0, 'z': 1} (512, 512) +{'t': 0, 'p': 0, 'c': 0, 'z': 2} (512, 512) +... +{'t': 2, 'p': 1, 'c': 1, 'z': 4} (512, 512) +``` -The engine accepts **any iterable of `MDAEvent`s**, not just an -`MDASequence`. That makes it easy to run adaptive, "smart" microscopy where -each event is decided based on previous images: +## Where did the data go? + +The `output` argument writes the images to disk as they arrive, and the extension +picks the format. An `.ome.zarr` "file" is actually a folder. Each stage position +is stored as a separate image inside it (`experiment.ome.zarr/0`, +`experiment.ome.zarr/1`), as an array with dimensions `(t, c, z, y, x)`. You can +read it back with [zarr](https://zarr.readthedocs.io) (`pip install zarr`): ```python -from queue import Queue +import zarr + +data = zarr.open_array("experiment.ome.zarr/0/0", mode="r") +data.shape # (3, 2, 5, 512, 512) +``` -events = Queue() -STOP = object() -core.run_mda(iter(events.get, STOP)) # runs until STOP is put in the queue +If you'd rather have files that you can drag into Fiji, use +`output="experiment.ome.tiff"` (this needs the `io` extra: +`pip install "pymmcore-plus[io]"`). With more than one stage position, you get one +file per position, in a folder named after your output +(`experiment/experiment_p000.ome.tiff`, `experiment/experiment_p001.ome.tiff`). -events.put(useq.MDAEvent(channel="DAPI", exposure=10)) -events.put(useq.MDAEvent(x_pos=100, y_pos=200, channel="FITC")) -events.put(STOP) +See [Saving data](../pymmcore-plus/guides/saving_data.md) for more on formats and +options. + +## Beyond sequences + +`MDASequence` is just one way to make events. The engine will run *any* iterable +of `MDAEvent` objects, so a plain list works too: + +```python +events = [ + useq.MDAEvent(channel="DAPI", exposure=10), + useq.MDAEvent(channel="FITC", x_pos=x + 50, y_pos=y), +] +core.mda.run(events) ``` -## Where next? +The iterable needn't even be of known length. A generator or a +[`Queue`][queue.Queue] can decide on the next event based on previous images (see +[Event-driven acquisition](../pymmcore-plus/guides/event_driven_acquisition.md)). + +## Next steps -- [The acquisition engine](../pymmcore-plus/guides/mda_engine.md): everything about - running MDAs: pausing, cancelling, hardware triggering, serialization. -- [Event-driven acquisition](../pymmcore-plus/guides/event_driven_acquisition.md) - and [custom acquisition engines](../pymmcore-plus/guides/custom_engine.md). -- [Your first GUI](first-gui.md): run the same experiments from a graphical - interface. +[The acquisition engine](../pymmcore-plus/guides/mda_engine.md) covers running +acquisitions in more depth (pausing and cancelling, hardware triggering, etc...), +and [Building sequences](../useq-schema/guides/sequences.md) shows more of what +you can describe with an `MDASequence`. When you're ready for a graphical +interface, have a look at [your first GUI](first-gui.md). diff --git a/docs/getting-started/first-gui.md b/docs/getting-started/first-gui.md index e466162..60b2151 100644 --- a/docs/getting-started/first-gui.md +++ b/docs/getting-started/first-gui.md @@ -1,50 +1,60 @@ --- -title: Your first GUI +title: Your First GUI --- -# Your first GUI +# Your First GUI -There are two ways to get a graphical interface: +You can either use one of the existing applications +([pymmcore-gui](../pymmcore-gui/index.md) or +[napari-micromanager](../napari-micromanager/index.md)), or build your own from +[pymmcore-widgets](../pymmcore-widgets/widgets/index.md), a library of Qt widgets +for controlling a `CMMCorePlus`. -- **Use an application**: [pymmcore-gui](#use-an-application) or - [napari-micromanager](#use-an-application). -- **Build your own** from [pymmcore-widgets](../pymmcore-widgets/widgets/index.md), - a library of Qt widgets that control a `CMMCorePlus`. - -## Use an application +## Using an application === "pymmcore-gui" + Once it's [installed](../pymmcore-gui/install.md) (from GitHub, or as a + bundled app for Windows and macOS), start it with the demo devices: + ```sh - pip install git+https://github.com/pymmcore-plus/pymmcore-gui - mmgui + mmgui run --demo-config ``` - Or download a [bundled app](../pymmcore-gui/install.md). See - [Using pymmcore-gui](../pymmcore-gui/usage.md). + Without `--demo-config`, the app starts with no devices loaded. You can load + a configuration from the **Devices** menu, or start with + `mmgui -c path/to/config.cfg`. See + [Using pymmcore-gui](../pymmcore-gui/usage.md) for more. === "napari-micromanager" + Once it's [installed](../napari-micromanager/getting_started.md), start it + with: + ```sh - pip install "napari-micromanager[pyqt6]" napari-micromanager ``` - See [getting started with napari-micromanager](../napari-micromanager/getting_started.md). + Then pick a configuration file at the top left of the window (or start it + with `napari-micromanager -c path/to/config.cfg`). -## Build your own +## Building your own -Every widget in `pymmcore-widgets` connects to a `CMMCorePlus` instance (by -default, the global [`CMMCorePlus.instance()`][pymmcore_plus.CMMCorePlus.instance]), -listens to its [events](../pymmcore-plus/guides/events.md), and stays in sync with -the hardware — whether the change came from the widget, from another -widget, or from your own code. +Widgets in `pymmcore-widgets` use the global +[`CMMCorePlus.instance()`][pymmcore_plus.CMMCorePlus.instance] by default (see the +tip in [First steps](first-steps.md#connecting-to-events)), and they listen to the +core's [events](../pymmcore-plus/guides/events.md), so if you change something from +a script (or from another widget), they'll update to match. (A few widgets don't +use a core at all. The sequence editors, like +[`ChannelTable`][pymmcore_widgets.ChannelTable] and +[`MDASequenceWidget`][pymmcore_widgets.MDASequenceWidget], just edit `useq` +objects.) -Install the widgets with a Qt backend: - -```sh -pip install "pymmcore-widgets[PyQt6]" -``` +You'll need `pymmcore-widgets` and a Qt backend, PyQt6 or PySide6 (see +[Installation](install.md#installing-the-packages), and +[PyQt6 or PySide6?](../pymmcore-widgets/getting_started.md#installing-a-qt-backend) +if you're not sure which). The examples here import Qt from +[`qtpy`](https://github.com/spyder-ide/qtpy), so they run with either one. Then combine widgets like any other Qt widgets: @@ -80,18 +90,20 @@ window.show() app.exec() ``` -1. Widgets use the global core by default. Pass `mmcore=...` to a widget to use - a specific instance. +1. This is the core the widgets below will use, so loading the configuration here + is enough for all of them. Pass `mmcore=...` to a widget to use a different + core (the [`ConfigWizard`][pymmcore_widgets.ConfigWizard] calls it `core=`). -A slightly larger version of this example looks like this: +A slightly larger version of this example +([`custom_gui.py`](../pymmcore-widgets/getting_started.md), which adds a +configuration loader and a few more controls) looks like this: ![type:video](../pymmcore-widgets/images/my_widget.mp4){: style='width: 100%'} -## Where next? +## Next steps -- The [widget gallery](../pymmcore-widgets/widgets/index.md) shows every widget, - each with a screenshot and a runnable example. -- [Getting started with pymmcore-widgets](../pymmcore-widgets/getting_started.md) - walks through a complete custom GUI. -- [Qt integration](../pymmcore-plus/examples/integration-with-qt.md) explains how - to keep the GUI responsive while the hardware works. +The [widget gallery](../pymmcore-widgets/widgets/index.md) is the best place to +find widgets for your own GUI (each one has a screenshot and a runnable example). +[Building a custom GUI](../pymmcore-widgets/getting_started.md) walks through the +larger example above, and [Qt integration](../pymmcore-plus/examples/integration-with-qt.md) +explains how to keep the GUI responsive while the hardware is busy. diff --git a/docs/getting-started/first-steps.md b/docs/getting-started/first-steps.md index e165255..b4a9ca7 100644 --- a/docs/getting-started/first-steps.md +++ b/docs/getting-started/first-steps.md @@ -1,19 +1,35 @@ --- -title: First steps +title: First Steps --- -# First steps: scripting the core +# First Steps -Everything starts with [`CMMCorePlus`][pymmcore_plus.CMMCorePlus], a drop-in -replacement for Micro-Manager's `CMMCore` with a more Pythonic API. If you have -used Micro-Manager's Java API or `pymmcore`, everything you know still applies. +At the heart of Micro-Manager is its *core* (`CMMCore`): a C++ library that loads +your hardware configuration, talks to each device through its device adapter, and +hands you images. Micro-Manager's Java application (MMStudio) is built on top of +it, and [`pymmcore`](https://github.com/micro-manager/pymmcore) makes it available +in Python. -!!! info "Before you start" +The main object in `pymmcore-plus` is [`CMMCorePlus`][pymmcore_plus.CMMCorePlus], +an enhanced subclass of `pymmcore.CMMCore`. It aims to be a drop-in replacement, +so if you've used `pymmcore` (or the `mmc` object in Micro-Manager's script +panel), the methods you know are all still there. `CMMCorePlus` adds a number of +things on top, and we'll look at a few of them here. - [Install](install.md) `pymmcore-plus` and the device adapters. These examples - use Micro-Manager's **demo configuration**, so no hardware is needed. +These examples use Micro-Manager's demo devices, so you don't need any hardware. +You can paste them into a Python session (`python`, `ipython` or a Jupyter +notebook) started in the environment where you [installed](install.md) +`pymmcore-plus`, or put them in a script. -## Load a configuration +!!! warning "Is Micro-Manager open?" + + `CMMCorePlus` doesn't connect to a running copy of Micro-Manager; it opens the + devices itself. Most hardware can only be opened by one program at a time, so + close Micro-Manager (MMStudio) before you load the same configuration here. + (The demo devices don't mind.) See + [Coming from Micro-Manager](from-micro-manager.md) for more. + +## Loading a configuration ```python from pymmcore_plus import CMMCorePlus @@ -26,12 +42,13 @@ print(core.getLoadedDevices()) ``` 1. With no argument, this loads the demo configuration (`MMConfig_demo.cfg`). - Pass the path to your own `.cfg` file to use your hardware. + Pass the path to your own `.cfg` file to use your hardware (see + [Using your own microscope](../guide/own-microscope.md)). -## Snap an image +## Snapping an image ```python -core.setExposure(25) +core.setExposure(25) # milliseconds image = core.snap() # (1)! print(image.shape, image.dtype) # (512, 512) uint16 ``` @@ -39,9 +56,24 @@ print(image.shape, image.dtype) # (512, 512) uint16 1. [`snap`][pymmcore_plus.CMMCorePlus.snap] is a `CMMCorePlus` convenience that calls `snapImage()` and returns `getImage()` as a numpy array. -## Read and set properties +`image` is just a numpy array, so you can look at it or save it with whatever +tools you like. For example, to save it as a TIFF (that you can open in Fiji), +use [tifffile](https://github.com/cgohlke/tifffile), which comes with the `io` +extra (or `pip install tifffile`): + +```python +import tifffile + +tifffile.imwrite("snap.tif", image) +``` + +(In a Jupyter notebook, `matplotlib.pyplot.imshow(image)` is a quick way to have a +look at it.) + +## Reading and setting properties -Every device exposes **properties**. You can use the classic string-based API: +Each device has a number of *properties*. You can use the classic string-based +API: ```python core.getProperty("Camera", "Binning") # '1' @@ -61,10 +93,11 @@ gain.range() # (-5.0, 8.0) See [Devices & properties](../pymmcore-plus/guides/devices.md) for more. -## Use configuration groups +## Configuration groups and presets -Configuration groups (or *presets*) bundle many property settings under one -name, such as an imaging channel: +A *configuration group* (such as "Channel") holds a number of *presets* (such as +"DAPI" or "FITC"), each of which sets several properties at once. These are the +same groups and presets you'd see in Micro-Manager's main window: ```python core.getAvailableConfigs("Channel") # ('Cy5', 'DAPI', 'FITC', 'Rhodamine') @@ -72,32 +105,57 @@ core.setConfig("Channel", "FITC") core.getCurrentConfig("Channel") # 'FITC' ``` -## Move stages +See [Configuration groups](../pymmcore-plus/guides/configuration.md) for more. + +## Moving stages + +Positions are in microns: ```python core.setXYPosition(100, 200) core.setZPosition(10) -core.waitForSystem() +core.waitForSystem() # (1)! core.getXYPosition() # [100.005, 199.995] ``` -## React to changes +1. Stage movements may return before the hardware has finished moving. + `waitForSystem` blocks until all devices are done. -`CMMCorePlus` emits [events](../pymmcore-plus/guides/events.md) whenever the -state of the microscope changes, so your code (or a GUI) can stay in sync: +## Connecting to events + +`CMMCorePlus` emits [events](../pymmcore-plus/guides/events.md) as the state of +the microscope changes, and you can connect your own callbacks to them. (This is +how the widgets in `pymmcore-widgets` stay in sync with the core.) ```python -@core.events.propertyChanged.connect +@core.events.propertyChanged.connect # (1)! def on_change(device: str, prop: str, value: str) -> None: print(f"{device}.{prop} changed to {value}") core.setProperty("Camera", "Gain", "2") # prints "Camera.Gain changed to 2" ``` -## Where next? +1. Using `connect` as a decorator registers the function below it as a callback. + `core.events.propertyChanged.connect(on_change)` does the same thing. + +!!! tip "`CMMCorePlus.instance`" + + Creating/accessing a `CMMCorePlus` object using + [`CMMCorePlus.instance()`][pymmcore_plus.CMMCorePlus.instance] is a + convenient way to access the same core instance from multiple places in your + code. The widgets in [`pymmcore-widgets`](../pymmcore-widgets/widgets/index.md) + also use `CMMCorePlus.instance()` by default, so any widgets you use will + automatically connect to the same core instance without any additional + configuration. + + Attempts *are* made to make it thread-safe. But please open an issue if you + find any problems. + +## Next steps -- Every method of the core, with what `pymmcore-plus` adds or changes, is listed - in the [`CMMCorePlus` reference][pymmcore_plus.CMMCorePlus]. -- [Your first acquisition](first-acquisition.md): run multi-dimensional - experiments. -- The [User Guide](../guide/index.md) covers each topic in depth. +From here, you'll probably want to run +[your first acquisition](first-acquisition.md). When you're ready to use a real +microscope, see [Using your own microscope](../guide/own-microscope.md). The +[User Guide](../guide/index.md) covers each topic in more depth, and the +[`CMMCorePlus` reference][pymmcore_plus.CMMCorePlus] lists all of the core's +methods, marking what `pymmcore-plus` adds or changes. diff --git a/docs/getting-started/from-micro-manager.md b/docs/getting-started/from-micro-manager.md new file mode 100644 index 0000000..3aecf5d --- /dev/null +++ b/docs/getting-started/from-micro-manager.md @@ -0,0 +1,130 @@ +--- +title: Coming from Micro-Manager +--- + +# Coming from Micro-Manager + +If you've used Micro-Manager's Java application (MMStudio), much of what you know +carries over. `pymmcore-plus` uses the same C++ core (`MMCore`) and the same +device adapters as MMStudio, and it reads the same configuration files. What's +different is everything above the core. In place of MMStudio's Java GUI and +acquisition engine, you get a Python object +([`CMMCorePlus`][pymmcore_plus.CMMCorePlus]), an acquisition engine written in +Python, and (if you want a GUI) widgets and applications written in Python. + +## One program at a time + +`pymmcore-plus` doesn't connect to a running copy of Micro-Manager; it *replaces* +it. Each program loads its own copy of the core, and the core opens your devices +itself (serial ports, camera drivers, etc...). Most devices can only be opened by +one program at a time, so if MMStudio has your configuration loaded, loading it +again from Python will usually fail (and if it doesn't, the two programs will be +fighting over the same hardware). So please close MMStudio before you load your +configuration in Python, and vice versa. (The demo devices don't mind, so it's +fine to have both open while you're trying things out.) + +If you want to keep using MMStudio *and* control it from Python at the same time, +that's what Pycro-Manager is for (see [below](#how-is-this-different-from-pycro-manager)). + +## Where things went + +| In MMStudio | Here | +| --- | --- | +| Hardware Configuration Wizard and `.cfg` files | The same files: `core.loadSystemConfiguration("MyScope.cfg")`. See [Using your own microscope](../guide/own-microscope.md). | +| Device Property Browser | `core.getProperty()` and `core.setProperty()`, or the [`PropertyBrowser`][pymmcore_widgets.PropertyBrowser] widget | +| Groups and presets (main window) | `core.setConfig("Channel", "FITC")`, or [`GroupPresetTableWidget`][pymmcore_widgets.GroupPresetTableWidget] | +| Snap and Live | `core.snap()` and `core.startContinuousSequenceAcquisition()`, or [`SnapButton`][pymmcore_widgets.SnapButton] and [`LiveButton`][pymmcore_widgets.LiveButton] | +| Multi-D Acquisition window | Describe the experiment with a [`useq.MDASequence`][useq.MDASequence] and run it with `core.mda.run()` (see [Your first acquisition](first-acquisition.md)), or use the [`MDAWidget`][pymmcore_widgets.MDAWidget] | +| Stage Position List | `stage_positions` in an `MDASequence` | +| Saving acquisitions | `core.mda.run(sequence, output="data.ome.zarr")` (or `.ome.tiff`). See [Saving data](../pymmcore-plus/guides/saving_data.md). | +| Pixel Size Calibration | The same pixel size presets in your `.cfg` file (`core.getPixelSizeUm()`), or the [`PixelConfigurationWidget`][pymmcore_widgets.PixelConfigurationWidget] | +| Script panel (Beanshell) | Python (see [below](#scripts)) | +| Device adapters (the `mmgr_dal_*` files) | The very same files. `mmcore install` downloads them for you. | + +If you'd rather have all of this in one window, [pymmcore-gui](../pymmcore-gui/index.md) +is our (work in progress) pure-Python replacement for MMStudio. + +## Scripts + +`CMMCorePlus` has the same methods as the core object (`mmc`) in MMStudio's script +panel, so Beanshell scripts translate to Python almost line by line: + +=== "Beanshell (MMStudio)" + + ```java + mmc.setConfig("Channel", "FITC"); + mmc.setExposure(50); + mmc.snapImage(); + img = mmc.getImage(); + ``` + +=== "Python" + + ```python + from pymmcore_plus import CMMCorePlus + + mmc = CMMCorePlus.instance() + mmc.loadSystemConfiguration() # (1)! + mmc.setConfig("Channel", "FITC") + mmc.setExposure(50) + mmc.snapImage() + img = mmc.getImage() # a numpy array + ``` + + 1. MMStudio had already done this for you. Pass the path to your own + `.cfg` file (with no argument, this loads the demo devices). + +A few things are different: images come back as numpy arrays (rather than Java +arrays), lists of strings come back as Python tuples (rather than `StrVector`s), +and `CMMCorePlus` adds a number of conveniences on top (like `snap()`, which does +the last two lines in one go). The `mm` object (MMStudio's own API for displays, +data stores, the acquisition manager, etc...) is part of the Java GUI, so it has no +equivalent here. + +If you're working inside one of our applications, the core is already there for +you. In [pymmcore-gui](../pymmcore-gui/index.md)'s console it's called `mmc` +(just like in MMStudio), and in +[napari-micromanager](../napari-micromanager/index.md)'s console it's `mmcore`. + +## How is this different from Pycro-Manager? + +A common question about `pymmcore-plus` and related libraries is how they differ +from [Pycro-Manager](https://github.com/micro-manager/pycro-manager), another +Python library written by Henry Pinkard for working with Micro-Manager. + +The two projects are not mutually exclusive! They aim to support different use +cases. + +The primary difference is that Pycro-Manager (with its default Java backend) +controls Micro-Manager via a ZMQ-based connection to a remote Java process in +which the Micro-Manager GUI is running (or a headless Java process if the GUI is +not running). The Java process in turn communicates with the C++ core driving the +hardware. + +`pymmcore-plus` aims to remove the need for a Java process by communicating +directly with the C++ core (via the +[`pymmcore`](https://github.com/micro-manager/pymmcore) library), and +re-implementing any necessary event-based or GUI-based functionality in pure +Python or C. + +A major advantage of Pycro-Manager is that you get the entire Java-based +Micro-Manager ecosystem, including the GUI and plugins. A potential disadvantage +(depending on your needs) is that it requires a Java runtime environment and +cross-process communication. + +(Pycro-Manager has since added a +[Python backend](https://pycro-manager.readthedocs.io/en/latest/backends.html) for +its headless mode, which also runs the core inside the Python process via +`pymmcore`, along with a Python version of its acquisition engine. It's a +separate project from `pymmcore-plus`, with its own API.) + +If you are looking to control a microscope via Python without any need for Java or +interprocess communication, `pymmcore-plus` may help. Note, however, that you will +be sacrificing the MMStudio GUI! See [pymmcore-gui](../pymmcore-gui/index.md), +[napari-micromanager](../napari-micromanager/index.md) and +[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) for our ongoing efforts to +implement a complete Python-based GUI for Micro-Manager. + +If you've been using Pycro-Manager and like its API, see +[A Pycro-Manager-style API](../pymmcore-plus/examples/pycro-manager-api.md) for an +example of what that could look like on top of `pymmcore-plus`. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md deleted file mode 100644 index ae97ffe..0000000 --- a/docs/getting-started/index.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Which package do I need? ---- - -# Which package do I need? - -The pymmcore-plus ecosystem is a set of packages that build on each other. -Most people need just one or two of them: - -| If you want to… | Use | Start here | -| --- | --- | --- | -| Control a microscope with a point-and-click app | **pymmcore-gui** | [Applications](../pymmcore-gui/index.md) | -| Control a microscope from inside [napari](https://napari.org) | **napari-micromanager** | [napari-micromanager](../napari-micromanager/index.md) | -| Script your microscope from Python or Jupyter | **pymmcore-plus** | [First steps](first-steps.md) | -| Run multi-dimensional acquisitions from code | **pymmcore-plus** + **useq-schema** | [Your first acquisition](first-acquisition.md) | -| Describe experiments in a hardware-agnostic way | **useq-schema** | [Describing experiments](../useq-schema/index.md) | -| Build your own Qt GUI for a microscope | **pymmcore-widgets** | [Your first GUI](first-gui.md) | - -``` mermaid -graph BT - hw[Your hardware] --> mm["Micro-Manager device adapters
(C++, via pymmcore)"] - mm --> plus[pymmcore-plus] - useq[useq-schema] --> plus - plus --> widgets[pymmcore-widgets] - widgets --> gui[pymmcore-gui] - widgets --> napari[napari-micromanager] - click plus "../pymmcore-plus/" - click useq "../useq-schema/" - click widgets "../pymmcore-widgets/widgets/" - click gui "../pymmcore-gui/" - click napari "../napari-micromanager/" -``` - -Installing a package also installs everything it builds on: `pymmcore-widgets` -brings in `pymmcore-plus`, which brings in `useq-schema` and `pymmcore`. The one -thing you always need to install separately is the set of **Micro-Manager device -adapters** (the drivers for your hardware) — see -[Installation](install.md). - -## What each package does - -[pymmcore-plus](../pymmcore-plus/index.md) -: The foundation. [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] is a drop-in - replacement for Micro-Manager's `CMMCore` with a Pythonic API, an event - system, and a flexible acquisition engine. Also includes the `mmcore` - command line tool. - -[useq-schema](../useq-schema/index.md) -: A hardware-agnostic schema for multi-dimensional experiments - ([`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent]). Not - specific to Micro-Manager: any acquisition engine can consume it. - -[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) -: Qt widgets that control a `CMMCorePlus` instance: device and property - controls, configuration editors, MDA editors, image previews, and more. - -[pymmcore-gui](../pymmcore-gui/index.md) -: A complete desktop application combining all of the above (plus the - [ndv](https://github.com/pyapp-kit/ndv) viewer). - -[napari-micromanager](../napari-micromanager/index.md) -: A napari plugin providing microscope control inside the napari viewer. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index d1a246c..94adaa0 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -4,16 +4,40 @@ title: Installation # Installation -Installation has two parts: +There are two things to install: the Python package(s) you want, and +Micro-Manager's device adapters (the C++ drivers for your hardware). The device +adapters include a set of demo devices, so you can try out the examples in these +docs without a microscope. -1. **The Python packages** you want (they pull in everything they build on). -2. **Micro-Manager's device adapters**: the C++ drivers for your hardware, - including a set of demo devices that let you try everything without a - microscope. +You'll need Python 3.11 or newer. -## 1. Install the packages +!!! question "Where do I type these commands?" -Pick what you need and copy the command: + In a terminal (on Windows, PowerShell or the Anaconda Prompt). We recommend + installing into a fresh environment rather than into your system Python. For + example, with [conda](https://conda-forge.org/download/): + + ```sh + conda create -n pymm python=3.12 + conda activate pymm + ``` + + ... or with [uv](https://docs.astral.sh/uv/), in the folder you'll be working + in: + + ```sh + uv venv # creates a .venv folder here + ``` + + The `uv add` command in the uv tab below adds a dependency to a uv *project* + (a folder with a `pyproject.toml`, which `uv init` creates). If you just made + an environment with `uv venv`, use `uv pip install` in place of `uv add`. + Either way, `uv run ` runs a command in that environment (e.g. + `uv run python my_script.py`). + +## Installing the packages + +Pick what you need, and copy the commands:
@@ -31,52 +55,79 @@ Pick what you need and copy the command:
-??? info "All the options, spelled out" +The parts in square brackets are optional *extras*. `cli` makes sure you get the +`mmcore` command line tool (it's currently included anyway), and `io` adds support +for saving OME-TIFF files. (Saving to OME-Zarr works without it.) + +??? info "All install options" | You want | Command | | --- | --- | | The core library and the `mmcore` CLI | `pip install "pymmcore-plus[cli]"` | - | … plus saving to OME-Zarr / OME-TIFF | `pip install "pymmcore-plus[cli,io]"` | + | … plus OME-TIFF writing (and `tifffile`) | `pip install "pymmcore-plus[cli,io]"` | | Qt widgets (choose a Qt backend) | `pip install "pymmcore-widgets[PyQt6]"` or `"pymmcore-widgets[PySide6]"` | - | The pymmcore-gui application | `pip install git+https://github.com/pymmcore-plus/pymmcore-gui` | - | The napari plugin | `pip install "napari-micromanager[pyqt6]"` | + | The pymmcore-gui application (needs `git`) | `pip install git+https://github.com/pymmcore-plus/pymmcore-gui` | + | The napari plugin | `pip install "napari-micromanager[PyQt6]"` | Each package brings in everything it builds on, so you only need the top-most one: `pymmcore-gui` already includes `pymmcore-widgets`, `pymmcore-plus[cli,io]` and PyQt6; `napari-micromanager` and `pymmcore-widgets` include `pymmcore-plus[cli]`. - Packages are on [PyPI](https://pypi.org/user/tlambert03/) and - [conda-forge](https://anaconda.org/conda-forge/pymmcore-plus). Python 3.10 or - newer is required. + All of these are on [PyPI](https://pypi.org/user/tlambert03/). (Some are on + conda-forge too, but those packages are currently out of date, so please use + `pip` inside your conda environment.) -## 2. Install the device adapters +## Installing the device adapters -The `mmcore` command (installed with the `cli` extra above) downloads the -latest compatible Micro-Manager device adapters for your platform: +The `mmcore` command downloads the latest compatible Micro-Manager device +adapters for your platform: ```sh mmcore install ``` -That's it: `CMMCorePlus` finds them automatically. For details (choosing a -version, using an existing Micro-Manager installation, Linux, and what "device -interface version" means) see +(If you used `uv venv`, that's `uv run mmcore install`.) + +They go into a folder in your user data directory, where `pymmcore-plus` will find +them automatically. For more details (choosing a +version, using an existing Micro-Manager installation, and what the "device +interface version" is), see [Micro-Manager device adapters](../pymmcore-plus/install.md). -!!! tip "Check your setup" +!!! note "Apple Silicon and Linux" + + On Apple Silicon Macs and on Linux, `mmcore install` currently downloads only + Micro-Manager's test adapters (the demo camera and a few others). That's all + you need for the examples in these docs. For real hardware on those + platforms, see + [Using your own microscope](../guide/own-microscope.md#getting-the-device-adapters). + +## Checking your setup + +`mmcore info` prints the versions of the relevant packages, and the folder that the +device adapters will be loaded from: + +```console +$ mmcore info +python : 3.14.2 (main, Dec 9 2025, 19:29:30) [Clang 21.1.4 ] +platform : macOS-27.0.1-arm64-arm-64bit-Mach-O +pymmcore-plus : 0.18.1 +pymmcore : 12.5.1.75.0 +pymmcore-nano : +core-version-info : MMCore version 12.5.1 +api-version-info : Device API version 75, Module API version 10 +adapter-path : ~/Library/Application Support/pymmcore-plus/mm/Micro-Manager-75.20260226 +useq-schema : 0.9.2 +``` - ```sh - mmcore info # show versions and the active Micro-Manager installation - mmcore bench # run a quick benchmark with the demo devices - ``` +If `adapter-path` says `not found`, `pymmcore-plus` can't find any device +adapters (did `mmcore install` work?). If something else went wrong, have a look +at [Troubleshooting](../pymmcore-plus/troubleshooting.md), and please let us know +on [image.sc](https://forum.image.sc/tag/pymmcore-plus) if that doesn't help. ## Next steps -
- -- [:octicons-arrow-right-24: **First steps**](first-steps.md): script the core -- [:octicons-arrow-right-24: **Your first acquisition**](first-acquisition.md) -- [:octicons-arrow-right-24: **Your first GUI**](first-gui.md) - -
+Once that's done, head to [First steps](first-steps.md) to start controlling the +demo devices from Python, or jump straight to +[your first acquisition](first-acquisition.md) or [your first GUI](first-gui.md). diff --git a/docs/getting-started/which-package.md b/docs/getting-started/which-package.md new file mode 100644 index 0000000..b651d0e --- /dev/null +++ b/docs/getting-started/which-package.md @@ -0,0 +1,61 @@ +--- +title: Which Package Do I Need? +--- + +# Which Package Do I Need? + +There are five packages here, each building on the ones below it (see the +[stack on the home page](../index.md#how-the-pieces-fit-together)). Most people +only need one of them: + +| If you want to… | Use | Start here | +| --- | --- | --- | +| Control a microscope with a point-and-click app | pymmcore-gui | [pymmcore-gui](../pymmcore-gui/index.md) | +| Control a microscope from inside [napari](https://napari.org) | napari-micromanager | [napari-micromanager](../napari-micromanager/index.md) | +| Script your microscope from Python or Jupyter | pymmcore-plus | [First steps](first-steps.md) | +| Run multi-dimensional acquisitions from code | pymmcore-plus (includes useq-schema) | [Your first acquisition](first-acquisition.md) | +| Describe experiments independently of any hardware | useq-schema | [Describing experiments](../useq-schema/index.md) | +| Build your own Qt GUI for a microscope | pymmcore-widgets | [Your first GUI](first-gui.md) | + +(A *multi-dimensional acquisition*, or MDA, is a series of images across time +points, stage positions, channels, z-planes, etc... It's what Micro-Manager's +Multi-D Acquisition window does.) + +Installing a package also installs the ones it builds on (`pip install +pymmcore-widgets` gets you `pymmcore-plus`, `useq-schema` and `pymmcore` too). The +Micro-Manager device adapters (the drivers for your hardware) are *not* Python +packages, though, and always need to be installed separately. +[Installation](install.md) covers both, so whichever package you pick, start there. + +If you've used Micro-Manager before, you may also want to read +[Coming from Micro-Manager](from-micro-manager.md). + +## What each package does + +[pymmcore-plus](../pymmcore-plus/index.md) +: Extends [`pymmcore`](https://github.com/micro-manager/pymmcore), the Python + bindings for Micro-Manager's C++ core. + [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] aims to be a drop-in replacement + for `CMMCore`, adding an event system, an acquisition engine written in Python, + and a number of convenience methods. It also provides the `mmcore` command + line tool. + +[useq-schema](../useq-schema/index.md) +: A schema for describing multi-dimensional acquisitions + ([`MDASequence`][useq.MDASequence], [`MDAEvent`][useq.MDAEvent]). It *tries* + to stay agnostic to the acquisition engine (though it was designed around what + Micro-Manager can do). + +[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) +: Qt widgets for a `CMMCorePlus`: property browsers, device controls, + configuration editors, MDA editors, image previews, etc... + +[pymmcore-gui](../pymmcore-gui/index.md) +: A desktop application built from all of the above (plus the + [ndv](https://github.com/pyapp-kit/ndv) viewer). The goal is a pure-Python + replacement for Micro-Manager's Java GUI. It's still early days, so expect + some rough edges. + +[napari-micromanager](../napari-micromanager/index.md) +: A napari plugin for controlling a microscope from the napari viewer. It's + still maintained, but current development is focused on pymmcore-gui. diff --git a/docs/guide/index.md b/docs/guide/index.md index cb18d4b..2cae061 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -4,11 +4,11 @@ title: User Guide # User Guide -The User Guide covers the whole ecosystem by topic, rather than by package. -Each page notes which package it is about, and links to the -[API reference](../api/index.md) for details. - -New here? Start with [Getting Started](../getting-started/index.md). +The User Guide is organized by topic, roughly following the stack from the +hardware up. Each page belongs to one package's docs (the badge at the top says +which), and names of classes and functions link to the +[API reference](../api/index.md). If you're new here, +[Getting Started](../getting-started/which-package.md) is probably a better place to begin.
@@ -16,51 +16,64 @@ New here? Start with [Getting Started](../getting-started/index.md). --- - Devices and properties, configuration groups, constants, and the event - system that keeps everything in sync. - - [:octicons-arrow-right-24: Devices & properties](../pymmcore-plus/guides/devices.md) + [Installing device adapters](../pymmcore-plus/install.md), + [using your own microscope](own-microscope.md), + [devices and properties](../pymmcore-plus/guides/devices.md), + [configuration groups](../pymmcore-plus/guides/configuration.md), and + [events and callbacks](../pymmcore-plus/guides/events.md) (which are how GUIs, + and your own code, stay in sync with the core). - :lucide-list-tree:{ .lg .middle } **Describing experiments** --- - `useq-schema`: an acquisition as a sequence of events (time, z, channels, - positions, grids, autofocus). - - [:octicons-arrow-right-24: Sequences and events](../useq-schema/index.md) + How [useq-schema](../useq-schema/index.md) describes an acquisition as a + sequence of events, and + [building sequences](../useq-schema/guides/sequences.md) with time points, + z-stacks, channels, positions, grids, etc... - :lucide-play:{ .lg .middle } **Running acquisitions** --- - The acquisition engine, saving data, event-driven (adaptive) acquisition, - and writing your own engine. - - [:octicons-arrow-right-24: The acquisition engine](../pymmcore-plus/guides/mda_engine.md) + [The acquisition engine](../pymmcore-plus/guides/mda_engine.md), + [saving data](../pymmcore-plus/guides/saving_data.md), + [event-driven acquisition](../pymmcore-plus/guides/event_driven_acquisition.md), + and [writing your own engine](../pymmcore-plus/guides/custom_engine.md). - :lucide-app-window:{ .lg .middle } **Building GUIs** --- - Compose `pymmcore-widgets` into your own Qt application. - - [:octicons-arrow-right-24: Widget gallery](../pymmcore-widgets/widgets/index.md) + The [widget gallery](../pymmcore-widgets/widgets/index.md), + [building a custom GUI](../pymmcore-widgets/getting_started.md) from those + widgets, and + [keeping it responsive](../pymmcore-plus/examples/integration-with-qt.md). - :lucide-microscope:{ .lg .middle } **Applications** --- - `pymmcore-gui` and `napari-micromanager`. - - [:octicons-arrow-right-24: pymmcore-gui](../pymmcore-gui/index.md) + [pymmcore-gui](../pymmcore-gui/index.md), our desktop application, and + [napari-micromanager](../napari-micromanager/index.md), which controls a + microscope from inside napari. - :lucide-cpu:{ .lg .middle } **Advanced topics** --- - Python device adapters (UniMMCore), logging, profiling, and more. - - [:octicons-arrow-right-24: UniMMCore](../pymmcore-plus/guides/unicore.md) + [Device adapters written in Python](../pymmcore-plus/guides/unicore.md) + (`UniMMCore`), a preview of [useq-schema v2](../useq-schema/v2-migration.md), + and a [Pycro-Manager-style API](../pymmcore-plus/examples/pycro-manager-api.md).
+ +!!! question "Something not working?" + + The troubleshooting pages cover + [the core and device adapters](../pymmcore-plus/troubleshooting.md) and + [Qt and widgets](../pymmcore-widgets/troubleshooting.md). Turning up the + [logging](../pymmcore-plus/guides/logging.md) (or + [profiling](../pymmcore-plus/profiling.md), if things are slow) can also help + you work out what's going on. If you're still stuck, please ask on the + [image.sc forum](https://forum.image.sc/tag/pymmcore-plus). diff --git a/docs/guide/own-microscope.md b/docs/guide/own-microscope.md new file mode 100644 index 0000000..3ea25b3 --- /dev/null +++ b/docs/guide/own-microscope.md @@ -0,0 +1,157 @@ +--- +title: Using Your Own Microscope +--- + +# Using Your Own Microscope + +[Getting Started](../getting-started/which-package.md) uses Micro-Manager's demo devices +throughout. To control real hardware, you need two more things: the device +adapters for your hardware, and a configuration file that says which devices you +have and how they're set up. + +(If Micro-Manager is running and connected to the same hardware, close it first. +See [One program at a time](../getting-started/from-micro-manager.md#one-program-at-a-time).) + +## What's a configuration file? + +A Micro-Manager configuration file (`.cfg`) is a plain text file listing the +devices to load (and which device adapter each one comes from), their initial +property values, which device plays which role (camera, shutter, focus stage), and +the configuration groups, presets and pixel sizes you've defined. Here are a few +lines from the demo configuration, `MMConfig_demo.cfg`: + +```text +# Devices +Device,Camera,DemoCamera,DCam +Device,Z,DemoCamera,DStage +Device,XY,DemoCamera,DXYStage +... +# Roles +Property,Core,Camera,Camera +Property,Core,Focus,Z +... +# Group: Channel +# Preset: FITC +ConfigGroup,Channel,FITC,Dichroic,Label,Q505LP +ConfigGroup,Channel,FITC,Emission,Label,Chroma-HQ535 +... +PixelSize_um,Res10x,1.0 +``` + +You rarely need to edit one by hand. If you already have a configuration file +from Micro-Manager, you can use it as it is (both use the same core, so they read +the same files). Micro-Manager's +[configuration guide](https://micro-manager.org/Micro-Manager_Configuration_Guide) +describes the format in detail. + +## Making a configuration file + +The Hardware Configuration Wizard walks you through adding your devices, setting +their properties, assigning roles and saving the file. There are three versions of +it, and you can use whichever is handiest: + +- In [pymmcore-gui](../pymmcore-gui/index.md): **Devices > Hardware Config + Wizard...** +- As a widget in your own Python code, + [`ConfigWizard`][pymmcore_widgets.ConfigWizard]: + + ```python + from pymmcore_widgets import ConfigWizard + from qtpy.QtWidgets import QApplication + + app = QApplication([]) + wizard = ConfigWizard() # (1)! + wizard.show() + app.exec() + ``` + + 1. Pass `config_file="MyScope.cfg"` to start from an existing file. Like most + widgets, it uses [`CMMCorePlus.instance()`][pymmcore_plus.CMMCorePlus.instance] + unless you pass a core (here, the argument is called `core=`). + +- In Micro-Manager itself: **Devices > Hardware Configuration Wizard...**. If + `mmcore install` downloaded a full Micro-Manager for you (on Windows and Intel + Macs), `mmcore mmstudio` will start it. + +## Getting the device adapters + +On Windows and Intel Macs, `mmcore install` downloads a complete Micro-Manager +nightly build, which includes the device adapters for most supported hardware. On +Apple Silicon Macs and on Linux, it currently downloads only the test adapters +(`DemoCamera`, `Utilities` and a few others), so for real hardware on those +platforms you'll need to build the adapters you need from +[source](https://github.com/micro-manager/mmCoreAndDevices). + +If you already have Micro-Manager installed (with the adapters you need), +`pymmcore-plus` can use it, as long as its *device interface version* matches the +one your `pymmcore` expects. `mmcore list` shows the installations it found (and +which of them are compatible), and `mmcore use ` picks one. See +[Micro-Manager device adapters](../pymmcore-plus/install.md) for more on all of +this. + +Some devices also need the manufacturer's own drivers or SDK installed (many +cameras do, for example). Micro-Manager's +[device support pages](https://micro-manager.org/Device_Support) say what each +adapter needs. + +## Loading it + +In Python, pass the path to +[`loadSystemConfiguration`][pymmcore_plus.CMMCorePlus.loadSystemConfiguration]: + +```python +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration("path/to/MyScope.cfg") # (1)! +``` + +1. Relative paths are looked for in the current directory first, then in the + device adapter folder. + +Both applications can load a configuration at startup: + +```sh +mmgui -c path/to/MyScope.cfg # pymmcore-gui +napari-micromanager -c path/to/MyScope.cfg # napari-micromanager +``` + +... and `mmcore bench -c path/to/MyScope.cfg` times a few basic operations on each +of your devices, which is a quick way to check that they load. + +If loading fails, the error message usually names the device that couldn't be +initialized. `mmcore logs` shows the end of the log file (which has more detail), +and [Troubleshooting](../pymmcore-plus/troubleshooting.md) covers the most common +problems. + +If you change the configuration from Python (for example, by defining new presets), +`core.saveSystemConfiguration("MyScope.cfg")` writes it back out. + +## Groups, channels and pixel sizes + +Once the devices are loaded, most day-to-day settings live in configuration groups +and their presets (see [Configuration groups](../pymmcore-plus/guides/configuration.md)). +When you list `channels` in an [`MDASequence`][useq.MDASequence], they're looked up +in a group named `Channel` by default. If your channel group has a different +name, say so: + +```python +import useq + +useq.MDASequence(channels=[{"config": "DAPI", "group": "Filters"}]) +``` + +Pixel sizes are stored in the configuration file too, as presets tied to your +objectives, and the core picks the right one as the objective changes. With the +demo configuration: + +```python +core.getAvailablePixelSizeConfigs() # ('Res10x', 'Res20x', 'Res40x') +core.setConfig("Objective", "20X") +core.getPixelSizeUm() # 0.5 +``` + +You can set these up with the +[`PixelConfigurationWidget`][pymmcore_widgets.PixelConfigurationWidget] (in +pymmcore-gui, **Window > Pixel Size Configuration**), and then save the +configuration. diff --git a/docs/index.md b/docs/index.md index 47e112b..258df5f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,14 +11,16 @@ hide:
-# Control microscopes with Python +# pymmcore-plus -**pymmcore-plus** and friends give you pure-Python access to the hundreds of -devices supported by [Micro-Manager](https://micro-manager.org): script your -hardware, describe and run multi-dimensional experiments, and build (or simply -use) graphical interfaces — no Java required. +These docs cover `pymmcore-plus` and the packages built around it, which let you +control a microscope from Python by talking directly to +[Micro-Manager](https://micro-manager.org)'s C++ core and device adapters (with no +Java process involved). You can use them to script your hardware and run +multi-dimensional acquisitions, or to build your own GUI (or just use one of +ours). -[Get started](getting-started/index.md){ .md-button .md-button--primary } +[Get started](getting-started/which-package.md){ .md-button .md-button--primary } [User guide](guide/index.md){ .md-button }
@@ -32,71 +34,42 @@ from pymmcore_plus import CMMCorePlus core = CMMCorePlus() core.loadSystemConfiguration() # demo devices -experiment = useq.MDASequence( +sequence = useq.MDASequence( channels=["DAPI", "FITC"], - z_plan={"range": 4, "step": 0.5}, - time_plan={"interval": 2, "loops": 10}, + time_plan={"interval": 1, "loops": 5}, ) -core.mda.run(experiment, output="data.ome.zarr") +core.mda.run(sequence, output="data.ome.zarr") ``` -## What would you like to do? +## Where to start -
+If you're new here, [Getting Started](getting-started/which-package.md) walks you through +installing things, controlling Micro-Manager's demo devices from Python, running a +first acquisition, and putting together a small GUI (no microscope required). If +you already know Micro-Manager, have a look at +[Coming from Micro-Manager](getting-started/from-micro-manager.md), which maps what +you know onto these packages. -- :lucide-terminal:{ .lg .middle } **Script a microscope** - - --- - - Load a configuration, move stages, snap images, and react to every change - from Python or Jupyter. - - [:octicons-arrow-right-24: First steps](getting-started/first-steps.md) - -- :lucide-layers:{ .lg .middle } **Run acquisitions** - - --- - - Describe time-lapses, z-stacks, channels and positions with `useq`, run them - with a hackable acquisition engine, and save to OME-Zarr or OME-TIFF. - - [:octicons-arrow-right-24: Your first acquisition](getting-started/first-acquisition.md) - -- :lucide-app-window:{ .lg .middle } **Build a GUI** - - --- - - Compose ready-made Qt widgets — property browsers, stage controls, - MDA editors, image previews — into your own application. - - [:octicons-arrow-right-24: Your first GUI](getting-started/first-gui.md) - -- :lucide-microscope:{ .lg .middle } **Just use an app** - - --- - - Point-and-click microscope control with `pymmcore-gui`, or inside napari - with `napari-micromanager`. - - [:octicons-arrow-right-24: Applications](pymmcore-gui/index.md) - -
+If you'd rather not write any code, [pymmcore-gui](pymmcore-gui/index.md) is our +(work in progress) desktop application, and +[napari-micromanager](napari-micromanager/index.md) controls a microscope from +inside [napari](https://napari.org). ## How the pieces fit together -Each layer builds on the ones below it, and every layer is usable on its own. -Click a package to learn more. +Each package builds on the ones below it. You only need to go as far up the stack +as you want (`pymmcore-plus` on its own is enough to script a microscope).
Applications
[**pymmcore-gui**](pymmcore-gui/index.md) · [**napari-micromanager**](napari-micromanager/index.md)
-
Complete microscope control applications.
+
Desktop applications for running a microscope.
@@ -108,33 +81,28 @@ Click a package to learn more.
Control & acquisition
[**pymmcore-plus**](pymmcore-plus/index.md) · [**useq-schema**](useq-schema/index.md)
-
A Pythonic core with events, an acquisition engine, and a hardware-agnostic schema for experiments.
+
The core, extended with events and an acquisition engine, and a schema for describing experiments.
Micro-Manager
[**pymmcore**](https://github.com/micro-manager/pymmcore) · [device adapters](pymmcore-plus/install.md)
-
The C++ core and drivers for hundreds of cameras, stages, shutters, lasers…
+
The C++ core and drivers for hundreds of cameras, stages, shutters, lasers, etc...
Your microscope
-
:lucide-microscope:   (or the built-in demo devices — no hardware needed)
+
:lucide-microscope:   Micro-Manager's demo devices, or your own hardware
-## Community +## Getting help -
- -- :fontawesome-brands-discourse: **Ask a question** on the - [image.sc forum](https://forum.image.sc/tag/pymmcore-plus) - -- :simple-zulip: **Chat with developers** on - [Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D) - -- :fontawesome-brands-github: **Report a bug or request a feature** on - [GitHub](https://github.com/pymmcore-plus) - -
+If you have usage questions (*"How do I...?"*), please ask on the +[image.sc forum](https://forum.image.sc/tag/pymmcore-plus) with the `pymmcore-plus` +tag, or come chat with us on +[Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D). +Bug reports and feature requests are always welcome on +[GitHub](https://github.com/pymmcore-plus) (each package has its own repository; +see [Contributing](contributing/index.md) if you're not sure which one). diff --git a/overrides/main.html b/overrides/main.html index 9ba3275..8bc3824 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% block announce %} - The pymmcore-plus, useq-schema, pymmcore-widgets, pymmcore-gui and - napari-micromanager docs now live together on one site. - Explore the new User Guide + The pymmcore-plus docs are now one site. + Report a problem {% endblock %} diff --git a/overrides/partials/content.html b/overrides/partials/content.html index 2506d15..1ed811c 100644 --- a/overrides/partials/content.html +++ b/overrides/partials/content.html @@ -7,9 +7,11 @@ {% if member %}
{{ member.title }} + {% if member.pypi %} PyPI version + {% endif %} {% include ".icons/fontawesome/brands/github.svg" %} diff --git a/redirects.toml b/redirects.toml index 0518521..1e158a2 100644 --- a/redirects.toml +++ b/redirects.toml @@ -3,7 +3,7 @@ [redirects] "pymmcore-plus/api/cmmcoreplus.md" = "pymmcore-plus/api/pymmcore_plus/CMMCorePlus.md" "pymmcore-plus/api/mda.md" = "pymmcore-plus/api/pymmcore_plus/mda/index.md" -"pymmcore-plus/api/events.md" = "pymmcore-plus/guides/events.md" +"pymmcore-plus/api/events.md" = "pymmcore-plus/reference/events.md" "pymmcore-plus/api/device.md" = "pymmcore-plus/guides/devices.md" "pymmcore-plus/api/configuration.md" = "pymmcore-plus/guides/configuration.md" "pymmcore-plus/api/constants.md" = "pymmcore-plus/guides/constants.md" @@ -14,7 +14,11 @@ # pages that moved into the unified User Guide or API reference "pymmcore-plus/examples/napari-micromanager.md" = "napari-micromanager/scripting.md" +"pymmcore-plus/examples/mda.md" = "pymmcore-plus/guides/mda_engine.md" +"pymmcore-plus/examples/context-set.md" = "pymmcore-plus/guides/devices.md#temporarily-changing-settings" +"pymmcore-plus/examples/following_changes_in_core.md" = "pymmcore-plus/guides/events.md#following-changes-in-the-core" "useq-schema/api.md" = "useq-schema/api/useq/index.md" +"getting-started/index.md" = "getting-started/which-package.md" # per-widget pages now live in the pymmcore-widgets API reference "pymmcore-widgets/widgets/CameraRoiWidget.md" = "pymmcore-widgets/api/pymmcore_widgets/CameraRoiWidget.md" diff --git a/tools/api.py b/tools/api.py index a4e331a..fd968fc 100644 --- a/tools/api.py +++ b/tools/api.py @@ -48,6 +48,7 @@ class ApiConfig: module_options: dict[str, dict[str, Any]] = field(default_factory=dict) api_root_uri: str = "api" exclude_private: bool = True + edit_base: str = "" # e.g. https://github.com//edit/main/ (for edit links) @classmethod def from_member(cls, member: str, root: Path, cfg: dict) -> ApiConfig | None: @@ -83,6 +84,7 @@ class ModulePage: members: list[str] = field(default_factory=list) reexports: list[tuple[str, str, str]] = field(default_factory=list) claimed: list[Claim] = field(default_factory=list) + children: list[ModulePage] = field(default_factory=list) # submodule pages @property def path(self) -> str: @@ -214,6 +216,8 @@ def plan( page = ModulePage(cfg, mod) pages[cfg.member].append(page) for name, obj in mod.members.items(): + if name.startswith("_") or name in NOT_API: + continue # e.g. __version__, or a private name in __all__ try: if not obj.is_public or obj.is_module: continue @@ -224,6 +228,8 @@ def plan( f"[api] unresolved alias {mod.path}.{name}", file=sys.stderr ) continue + if "_deprecated" in target.split("."): + continue # still importable, but not to be documented locations[target].append((f"{mod.path}.{name}", page, name)) aliases: dict[str, str] = {} @@ -264,46 +270,102 @@ def rank( # --------------------------------------------------------------------------- -def _options(page: ModulePage) -> dict[str, Any]: - options: dict[str, Any] = {"heading_level": 1, "show_root_heading": True} +# module-level names that are never API, even when griffe considers them public +NOT_API = {"logger", "log"} +GROUPS = {"class": "Classes", "function": "Functions"} # anything else: Attributes + + +def _options(page: ModulePage, **overrides: Any) -> dict[str, Any]: + options: dict[str, Any] = {} for pattern, local in page.config.module_options.items(): if re.match(pattern, page.path): options.update(local) - options["members"] = sorted(page.members) if page.members else False - options["show_submodules"] = False - return options + return {**options, **overrides} + + +def _member_rows(page: ModulePage) -> dict[str, list[tuple[str, str, str]]]: + """(name, identifier, summary) of everything in the module, grouped by kind.""" + groups: dict[str, list[tuple[str, str, str]]] = defaultdict(list) + for name in page.members: + obj = page.module.members[name] + group = GROUPS.get(_kind(obj), "Attributes") + groups[group].append((name, f"{page.path}.{name}", _summary(obj))) + for c in page.claimed: + groups[GROUPS.get(c.kind, "Attributes")].append((c.name, c.path, c.summary)) + return { + g: sorted(groups[g]) + for g in ("Classes", "Functions", "Attributes") + if g in groups + } def render(page: ModulePage) -> str: - title = page.path.rsplit(".", 1)[-1] lines = [ "---", - f"title: {json.dumps(title)}", + f"title: {json.dumps(page.path)}", f"package: {json.dumps(page.config.member)}", "generated: true", - "---", - "", - f"::: {page.path}", - f" options: {json.dumps(_options(page))}", - "", ] + # "edit" opens the module's source: that's where its docs (docstrings) live + filepath = page.module.filepath + if page.config.edit_base and isinstance(filepath, Path): + rel = filepath.resolve().relative_to(page.config.root.resolve()).as_posix() + lines.append(f"edit_url: {json.dumps(page.config.edit_base + rel)}") + # the module itself: heading and docstring + root = _options( + page, + heading_level=1, + show_root_heading=True, + members=False, + show_submodules=False, + ) + lines += ["---", "", f"::: {page.path}", f" options: {json.dumps(root)}", ""] + + # an index of the package: submodules, then everything in it by kind (linking + # to where each object is documented: below, or on a page of its own) + def table(label: str, rows: list[tuple[str, str, str]]) -> None: + lines.extend([f"**{label}**", "", "| Name | Description |", "| --- | --- |"]) + lines.extend(f"| [`{n}`][{ident}] | {summary} |" for n, ident, summary in rows) + lines.append("") + + if page.children: + subs = sorted(page.children, key=lambda p: p.path) + table( + "Submodules", + [(c.path.rsplit(".", 1)[-1], c.path, _summary(c.module)) for c in subs], + ) + groups = _member_rows(page) + for label, rows in groups.items(): + table(label, rows) + + # then the full documentation of what's documented here, in source order slug = page.path.replace(".", "-") - if page.claimed: - lines += [ - f"## Documented on other pages {{#{slug}--elsewhere}}", - "", - "| Object | Page | Description |", - "| --- | --- | --- |", + member = _options( + page, heading_level=3, show_root_heading=True, show_root_full_path=False + ) + for label in groups: + names = [n for n in page.module.members if n in page.members] + names = [ + n + for n in names + if GROUPS.get(_kind(page.module.members[n]), "Attributes") == label ] - for c in sorted(page.claimed, key=lambda c: c.name): - lines.append(f"| [`{c.name}`][{c.path}] | {c.title} | {c.summary} |") - lines.append("") + if not names: + continue + lines += [f"## {label} {{#{slug}--{label.lower()}}}", ""] + for name in names: + lines += [ + f"::: {page.path}.{name}", + f" options: {json.dumps(member)}", + "", + ] + if page.reexports: lines += [ f"## Re-exports {{#{slug}--reexports}}", "", - "These names can be imported from this module, but are documented " - "at their top-level location.", + "These can also be imported from this module, but each is documented " + "under its shortest import path (linked below).", "", "| Name | Description |", "| --- | --- |", @@ -323,6 +385,22 @@ def write( the docs-relative paths written. """ written: list[str] = [] + # drop pages that would only list re-exports (their names still resolve, via + # aliases); a package page stays if any submodule page does. Deepest first. + kept: list[ModulePage] = [] + for page in sorted(pages, key=lambda p: -p.path.count(".")): + page.children = [c for c in kept if c.path.rsplit(".", 1)[0] == page.path] + if ( + page.members + or page.claimed + or page.children + or "." not in page.path + or page.rel_path() in handwritten + ): + kept.append(page) + kept_ids = {id(p) for p in kept} + pages = [p for p in pages if id(p) in kept_ids] + dirs: dict[str, dict[str, Any]] = defaultdict( lambda: {"index": None, "modules": [], "subdirs": set(), "objects": []} ) @@ -334,9 +412,11 @@ def write( if "." in page.path: parent, leaf = page.rel_dir().rsplit("/", 1) dirs[parent]["subdirs"].add(leaf) - # hand-written object pages living in this package's directory + # hand-written object pages living in this package's directory (not + # the package page itself, when a hand-written one replaces it) for c in page.claimed: - if c.page.rsplit("/", 1)[0] == f"{mount}/{page.rel_dir()}": + folder, name = c.page.rsplit("/", 1) + if folder == f"{mount}/{page.rel_dir()}" and name != "index.md": entry["objects"].append(c) else: entry["modules"].append(page) @@ -355,10 +435,13 @@ def write( nav.append( {KIND_PREFIX.format(kind=c.kind) + c.name: c.page.rsplit("/", 1)[1]} ) - for mod in sorted(entry["modules"], key=lambda p: p.path): - leaf = mod.path.rsplit(".", 1)[-1] - nav.append({f"{MOD_PREFIX}{leaf}": f"{leaf}.md"}) - nav += sorted(entry["subdirs"]) + # modules and subpackages together, alphabetically + children = {mod.path.rsplit(".", 1)[-1]: mod for mod in entry["modules"]} + for leaf in sorted({*children, *entry["subdirs"]}): + if leaf in children: + nav.append({f"{MOD_PREFIX}{leaf}": f"{leaf}.md"}) + else: + nav.append(leaf) title = MOD_PREFIX + entry["index"].path.rsplit(".", 1)[-1] nav_file = out_docs / mount / rel_dir / ".nav.yml" nav_file.write_text( diff --git a/tools/compose.py b/tools/compose.py index f72db41..9adc12a 100644 --- a/tools/compose.py +++ b/tools/compose.py @@ -238,6 +238,7 @@ def api_configs(members: list[Member]) -> list[api.ApiConfig]: m.name, m.root, m.plugins.get("api-autonav", {}) ): cfg.modules = [str(m.root / p) for p in cfg.modules] + cfg.edit_base = f"https://github.com/{m.repo}/edit/{m.branch}/" out.append(cfg) return out @@ -477,6 +478,19 @@ def split_inventory(members: list[Member]) -> None: print(f"[compose] {m.name}/objects.inv: {len(sub)} objects") +# symbol badges (e.g. "mod", "c") in the API nav titles, possibly escaped +NAV_SYMBOL = re.compile(r"\\?\\?") + + +def clean_llms_txt() -> None: + """Strip markup that the llmstxt plugin copies from nav titles.""" + for name in ("llms.txt", "llms-full.txt"): + path = SITE / name + if path.exists(): + text = NAV_SYMBOL.sub("", path.read_text(encoding="utf-8")) + path.write_text(text.replace("&amp;", "&"), encoding="utf-8") + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -495,7 +509,8 @@ def assemble(members: list[Member], *, regenerate: bool = True) -> dict[str, Any def _zensical_env() -> dict[str, str]: - # tools/ on the path so `pymm_mdx` (our Markdown extension) is importable + # tools/ on the path so `pymm_mdx` and `pymm_griffe` (our Markdown and griffe + # extensions) are importable path = os.pathsep.join( filter(None, [str(ROOT / "tools"), os.environ.get("PYTHONPATH")]) ) @@ -520,6 +535,7 @@ def cmd_build(ns: argparse.Namespace) -> int: if proc.returncode: return proc.returncode split_inventory(members) + clean_llms_txt() problems += check_orphans() if ns.scope: problems += [w for w in _warnings(output) if _in_scope(w, ns.scope, members)] diff --git a/tools/pymm_griffe.py b/tools/pymm_griffe.py new file mode 100644 index 0000000..f6656af --- /dev/null +++ b/tools/pymm_griffe.py @@ -0,0 +1,95 @@ +"""Griffe extension for the unified docs: tidy what griffe and griffe-fieldz produce. + +- griffe-fieldz falls back to a field's ``default_factory.__doc__`` for fields + without a description (a ``list`` field gets "Built-in mutable sequence..."), + uses a dataclass' auto-generated ``__doc__`` (its signature, with reprs) as the + class description, and reports types as ``ForwardRef('...')`` in modules using + ``from __future__ import annotations``. Undo all three. +- Drop the attribute-scope labels ("class-attribute", "instance-attribute", + "module-attribute"): the heading already says "attribute". +- Drop ``Kwargs`` TypedDicts nested in classes (useq defines them for typing only). +- Drop undocumented instance attributes of the Qt packages' classes: those are + child widgets (``self.btn = QPushButton()``), not API. +""" + +from __future__ import annotations + +import builtins +import inspect +import re +import types +from typing import TYPE_CHECKING, Any + +from griffe import ( + DocstringSectionAttributes, + DocstringSectionParameters, + DocstringSectionText, + Extension, +) + +if TYPE_CHECKING: + from griffe import Class, Module, Object + +BUILTIN_DOCS = { + inspect.cleandoc(t.__doc__) + for t in (*vars(builtins).values(), types.MappingProxyType) + if isinstance(t, type) and t.__doc__ +} +SCOPE_LABELS = {"class-attribute", "instance-attribute", "module-attribute"} +QT_PACKAGES = {"pymmcore_widgets", "pymmcore_gui"} +FORWARD_REF = re.compile(r"ForwardRef\('([^']*)'") + + +class TidyDocs(Extension): + def on_class(self, *, cls: Class, **kwargs: Any) -> None: + if cls.docstring: + sections = cls.docstring.parsed + # a dataclass' auto __doc__ ("Name(field: type = default, ...)") + if ( + sections + and isinstance(sections[0], DocstringSectionText) + and sections[0].value.startswith(f"{cls.name}(") + ): + del sections[0] + for section in sections: + if isinstance( + section, (DocstringSectionAttributes, DocstringSectionParameters) + ): + for item in section.value: + _tidy_item(cls, item) + + if "Kwargs" in cls.members and cls.members["Kwargs"].is_class: + del cls.members["Kwargs"] + + def on_package(self, *, pkg: Module, **kwargs: Any) -> None: + # after the whole tree is built: instance attributes are only known then + _tidy_tree(pkg, set(), qt=pkg.name in QT_PACKAGES) + + +def _tidy_item(cls: Class, item: Any) -> None: + if item.description and item.description.strip() in BUILTIN_DOCS: + item.description = "" + if item.annotation is not None and (m := FORWARD_REF.search(str(item.annotation))): + # prefer the member's own (static, cross-referenced) annotation + member = cls.members.get(item.name) + static = getattr(member, "annotation", None) if member else None + item.annotation = static or m.group(1) + + +def _tidy_tree(obj: Object, seen: set[str], *, qt: bool) -> None: + for name, member in list(obj.members.items()): + if member.is_alias or member.path in seen: + continue + seen.add(member.path) + if ( + qt + and obj.is_class + and member.is_attribute + and member.labels == {"instance-attribute"} + and not member.docstring + ): + del obj.members[name] + continue + member.labels -= SCOPE_LABELS + if member.is_class or member.is_module: + _tidy_tree(member, seen, qt=qt) diff --git a/tools/pymm_mdx.py b/tools/pymm_mdx.py index 1618c44..3be0105 100644 --- a/tools/pymm_mdx.py +++ b/tools/pymm_mdx.py @@ -9,8 +9,12 @@ Here, every rendered heading id under a preferred path is also registered under each alias path, so `[x][pymmcore_plus.core.Device.getPropertyObject]` resolves to the `pymmcore_plus.Device.getPropertyObject` heading. -2. Decorate "**Why Override?**" paragraphs in CMMCorePlus docstrings. +2. Decorate "**Why Override?**" paragraphs in CMMCorePlus docstrings, and drop + the "This method is new in CMMCorePlus." sentences (the method table and the + heading badges already say so; the docstrings keep it for IDEs). 3. Relabel "Attributes" as "Keys" on the metadata (TypedDict) reference page. +4. Tidy mkdocstrings output: private base classes in "Bases:" lines, and the + hub's own checkout path (`.repos//`) in "Source code in ..." labels. """ from __future__ import annotations @@ -34,6 +38,28 @@ 'viewBox="0 0 24 24">' f"{WHY}" ) +NEW_IN_CORE = re.compile( + r"

(?:(?!).)*?This (?:method|property) is new in " + r"CMMCorePlus\.

\s*", + re.DOTALL, +) +BASES = re.compile( + r'(

\s*Bases:\s*)(.*?)(

)', re.DOTALL +) +CODE_ITEM = re.compile(r".*?", re.DOTALL) +TAG = re.compile(r"<[^>]+>") +REPOS_PATH = re.compile(r"(Source code in )\.repos/[^/]+/") + + +def _public_bases(match: re.Match[str]) -> str: + """Drop private classes (``_Base``) from a "Bases: ..." paragraph.""" + items = CODE_ITEM.findall(match.group(2)) + public = [i for i in items if not TAG.sub("", i).strip().startswith("_")] + if len(public) == len(items): + return match.group(0) + if not public: + return "" + return match.group(1) + ", ".join(public) + match.group(3) @cache @@ -73,6 +99,12 @@ def run(self, text: str) -> str: break if WHY in text: # idempotent: mkdocstrings nests Markdown conversions text = re.sub(r"(?)" + re.escape(WHY), WHY_HTML, text) + if "is new in CMMCorePlus" in text: + text = NEW_IN_CORE.sub("", text) + if "doc-class-bases" in text: + text = BASES.sub(_public_bases, text) + if "Source code in .repos/" in text: + text = REPOS_PATH.sub(r"\1", text) src = str(getattr(page, "file", "") or getattr(page, "url", "")) if "pymmcore_plus/metadata" in src or "pymmcore_plus/metadata" in page.url: text = text.replace("Attributes:", "Keys:") diff --git a/zensical.base.toml b/zensical.base.toml index fe33edc..b5b261b 100644 --- a/zensical.base.toml +++ b/zensical.base.toml @@ -136,6 +136,8 @@ custom_checkbox = true [project.plugins.autorefs] resolve_closest = true +# no hover titles on internal links: they named the defining (private) module +link_titles = "external" [project.plugins.glightbox] # light/dark screenshot pairs (`#only-light` / `#only-dark`) form separate galleries @@ -152,10 +154,36 @@ video_loop = true markdown_description = "Python tools for controlling microscopes with Micro-Manager: pymmcore-plus (core + acquisition engine), useq-schema (experiment schema), pymmcore-widgets (Qt widgets), pymmcore-gui and napari-micromanager (applications)." full_output = "llms-full.txt" +# keep in sync with docs/.nav.yml: pages in no section get no "copy as Markdown" [project.plugins.llmstxt.sections] -"Getting started" = ["getting-started/*.md"] -"User guide" = ["*/guides/*.md", "useq-schema/schema/*.md"] -"API reference" = ["*/api/**/*.md"] +"Getting started" = ["index.md", "getting-started/*.md"] +"User guide" = [ + "guide/*.md", + "pymmcore-plus/install.md", + "*/guides/*.md", + "pymmcore-plus/examples/*.md", + "*/troubleshooting.md", + "pymmcore-plus/profiling.md", + "useq-schema/index.md", + "useq-schema/v2-migration.md", + "pymmcore-widgets/getting_started.md", + "pymmcore-widgets/widgets/index.md", + "pymmcore-gui/index.md", + "pymmcore-gui/install.md", + "pymmcore-gui/usage.md", + "napari-micromanager/*.md", +] +"API reference" = [ + "api/index.md", + "pymmcore-plus/index.md", + "pymmcore-widgets/index.md", + "*/api/**/*.md", + "useq-schema/schema/*.md", + "pymmcore-plus/reference/*.md", + "pymmcore-plus/cli.md", + "pymmcore-plus/env_var.md", +] +"Contributing" = ["contributing/*.md", "*/contributing.md"] [project.plugins.redirects.redirect_maps] # filled in by tools/compose.py from redirects.toml @@ -195,6 +223,11 @@ show_source = false show_symbol_type_heading = true show_symbol_type_toc = true signature_crossrefs = true -summary = true -extensions = [{ griffe_fieldz = { include_inherited = true, strip_annotated = true } }] +# module pages get index tables from tools/api.py; per-class tables only repeated the page +summary = false +# fields go into the docstring's Attributes section, keeping its descriptions +extensions = [ + { griffe_fieldz = { include_inherited = true, strip_annotated = true, add_fields_to = "docstring-attributes" } }, + "pymm_griffe:TidyDocs", +] preload_modules = [] From bd3b9596a3950238449b813d78e1d6767d6bd944 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 Oct 2026 07:02:44 +0100 Subject: [PATCH 12/15] fix(compose): match member warnings with Windows paths in --scope Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- tools/compose.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/compose.py b/tools/compose.py index 9adc12a..f1234ea 100644 --- a/tools/compose.py +++ b/tools/compose.py @@ -561,6 +561,7 @@ def _warnings(output: str) -> list[str]: def _in_scope(warning: str, scope: str, members: list[Member]) -> bool: + warning = warning.replace("\\", "/") # griffe reports Windows paths if scope == "hub": return not any(f"{m.name}/" in warning for m in members) return f"{scope}/" in warning From 1d9a31d03359fa6d552f8b5487551ecc0661cea9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 Oct 2026 09:53:43 +0100 Subject: [PATCH 13/15] docs: ecosystem-first home page, logo and brand colors, package diagram - home: restore the "Control microscopes with Python" hero and the task cards (2x2), community cards; add ome-writers to the stack - which-package: apps vs libraries, a Python/C++ dependency diagram (mermaid) with links out, ome-writers - coming from Micro-Manager: bring back the components diagram - use the pymmcore-plus logo (header, favicon, home hero) and switch the palette to the logo's blue, with its yellow as the accent - drop the announcement banner and the edit/view page actions - pymm.js: "%% pymm:align-groups" gives mermaid subgraphs a common width - compose: don't put docs/ in zensical's watch list, so hub page edits rebuild incrementally instead of reloading the whole site Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- README.md | 2 +- docs/assets/css/pymm.css | 45 ++++++--- docs/assets/img/favicon.ico | Bin 15406 -> 0 bytes docs/assets/img/favicon.png | Bin 0 -> 4839 bytes docs/assets/img/logo.svg | 1 + docs/assets/js/pymm.js | 44 ++++++++ docs/getting-started/from-micro-manager.md | 29 ++---- docs/getting-started/which-package.md | 112 ++++++++++++++++++--- docs/index.md | 93 ++++++++++++----- overrides/main.html | 6 -- tools/compose.py | 4 +- zensical.base.toml | 7 +- 12 files changed, 258 insertions(+), 85 deletions(-) delete mode 100644 docs/assets/img/favicon.ico create mode 100644 docs/assets/img/favicon.png create mode 100644 docs/assets/img/logo.svg delete mode 100644 overrides/main.html diff --git a/README.md b/README.md index 3bb1c39..651508d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ compose.toml members (name, GitHub repo, branch) zensical.base.toml theme, markdown extensions, plugins, global mkdocstrings options redirects.toml old URL -> new URL, for pages that moved docs/ hub pages, docs/.nav.yml (site navigation), assets/ -overrides/ theme template overrides (edit links, package badge, banner) +overrides/ theme template overrides (page actions, package badge) tools/repos.py clone / update / worktree member repos into .repos/ tools/compose.py generate -> copy -> API pages -> zensical.toml -> build tools/api.py de-duplicated API reference generation diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index 40e0a2e..1b0af4f 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -6,28 +6,32 @@ :root, [data-md-color-scheme="default"] { - --md-primary-fg-color: #0b6e8e; - --md-primary-fg-color--light: #2a90b2; - --md-primary-fg-color--dark: #08506a; + /* the logo's blue; its yellow is too light for text on white, so hovers use + a darker gold */ + --md-primary-fg-color: #3279b2; + --md-primary-fg-color--light: #5b9bd5; + --md-primary-fg-color--dark: #245a86; --md-primary-bg-color: #ffffff; - --md-accent-fg-color: #0d9488; - --md-accent-fg-color--transparent: #0d94881a; + --md-accent-fg-color: #a16207; + --md-accent-fg-color--transparent: #ffcb3533; --pymm-added: #b45309; - --pymm-overridden: #0b6e8e; + --pymm-overridden: #3279b2; --pymm-inherited: #6b7280; --pymm-surface: #f6f8fa; --pymm-border: #e1e4e8; } [data-md-color-scheme="slate"] { - --md-primary-fg-color: #38b2d6; - --md-primary-fg-color--light: #6ccbe8; - --md-primary-fg-color--dark: #1a8fb4; + /* a lighter logo blue (the logo's own is too dark for text here) and the + logo's yellow */ + --md-primary-fg-color: #6aa6d8; + --md-primary-fg-color--light: #8fbde4; + --md-primary-fg-color--dark: #3279b2; --md-primary-bg-color: #0f1419; - --md-accent-fg-color: #2dd4bf; - --md-accent-fg-color--transparent: #2dd4bf1a; + --md-accent-fg-color: #ffcb35; + --md-accent-fg-color--transparent: #ffcb351a; --pymm-added: #f59e0b; - --pymm-overridden: #38b2d6; + --pymm-overridden: #6aa6d8; --pymm-inherited: #9ca3af; --pymm-surface: #161b22; --pymm-border: #30363d; @@ -53,6 +57,12 @@ letter-spacing: -0.02em; } +.pymm-hero__logo { + display: block; + width: 4.5rem; + margin-bottom: 1.2rem; +} + .pymm-hero__text p { font-size: 0.95rem; } @@ -75,6 +85,17 @@ display: none; } +/* landing page task cards: 2x2, so none is left alone on a row */ +.md-typeset .grid.cards.pymm-tasks { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media screen and (max-width: 45em) { + .md-typeset .grid.cards.pymm-tasks { + grid-template-columns: minmax(0, 1fr); + } +} + /* Layered "how the pieces fit together" diagram */ .pymm-stack { display: flex; diff --git a/docs/assets/img/favicon.ico b/docs/assets/img/favicon.ico deleted file mode 100644 index 537987b939cd2daa0f299e35e85fe9ac144251fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHN`BxQn6rWE00ro@ZI=D6+50?fV&=Y2NVWy_vTF9^dzz`)20*zW3hGy>sWg-0zp; zUg2Kl+O^}@)sCC|8pqjl9A{_O^xXY*j*Dk?J$pX?zZ=J;zrk_cSsTW}IGXC&MQ?HX z-p%+0 z9|SrNqwp!NIyA-6o^Kz!Fo>72F?yaNzf13qIa=KesKtKA9(P^9bYb=$PJkqI?^5P!<3*}1IIi0qdX~*c+wLG0V zIe%HnV}YIWFJ*{rm(B8}XV0F|)>VtdIyo<%<@{lOPLz>ynCH#AaE9DJ?V^c4VPymr z6Yr$9OeA>^>!F!nF3+0J zdOHOTmFQ>e7!6~D{z<7=(*~RDw$X=cc@MkREu(9fFIt8E*cos1n>&4Ezxa(?`UYc- zF<;DNnBE%rA z4Vy{Q@fNHGGTPn{7Uy0a=;oCi(VlH<7BhM64a)Q# z(Nol4cq89nTSL0z!b9ls!~68Kp+VGNQ(Z-nIm2m)qFls1zCV#9~aXc&#%nVt;T*g`z};lQ!S|F*A!W2QV9LGvDi}czcK$~%)E2^CY!fh zAgn9#9@NNH+s2rFwQ@{t_pbmh=XF``QJ87Jb+H-jUbf_h{4dFiJVFTci{F zchREho`gAqJ|0WgP5A5sS{gi&u-*q@YZUh}q+)inS6I+#14*tgWZ+tY*gZ6BQX|`sO&hJ|49`ncx zr;cmJ|8wXRey!6|IFv=hb$N&#MhljmR zY01yd!v4uq3M_cEWXN~{C^$*`Bmh|Erm}>4syDwhxSs& ziNkd6#39-lx0JjFchjsF`4sHoBX)!sLatg#uchi!*?x?m6MCzwDrgqV6>F96&hnku z(~`FRYe&^1x>Ju-vwJt!9Sn%qe*N%d-_Nc-7C7Kv+oZZfW5r5 zgx`}fXWb=WG<4O9A9FjLi9~-2occ;_bv12deUO@SlZLRV`OhT$?5qmb<e8oUi(-c@n(;Fpxer?N*@nM$-^YvY|BRGBrQM+7JFegi9XRa`}w1$35$qXGoO79s~ zoL#ln+}BdrAqV-rFh6ICnCdPPa-<>dpSNkhTha$MS*<&k*!*wve`~}5J04^!HV>HW Gf&T!mH2fg| diff --git a/docs/assets/img/favicon.png b/docs/assets/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c5f19f65ba94a1e0dc4ce9590f7254afa4eebcb2 GIT binary patch literal 4839 zcmb_g_dnJD7k_iPu05ix#5JRAWn}Ly+1W(Mh>VMyi)*hN*QO+wu9;Ov*_)4w^&hzzpoyR%nHR6m6G^r_ADF6VV*49!pCbVt;3It53jeoo#0{}We zTTR&{C~s$h@|DS-e6oZ6eU}dIM;`>f4{Srw(9y5$Hs+7)k(E+GKTEH+$7~o5+ zUAd)p1ry<7Y66()sUqw|-w~~2M@bQ@ec|4tn*=Sp8-1dhZ6Xj?Wtd>76fnV#M-Gwc z4GM+02%ue6#BMyoyn5>Opj*VbM;zJ&D0B=Zk*t;I9qx|#Q`*J&n%aXzVgB6*3VK4! z4SH97)qNyG@A{|nMcq^vqED-oWQ0*feRifV`NQ{=z-e<^jZTNXKOPhbpszfD~XCfkL?~>Ipn0_u~Q4(7LczN~oo!EW7&$h38)FL^s9&24bA@YrO zl@{mAthaCU*z>lE8i>yRYl(9N<4)3TLlU(QxWSLfv_)MEA#u5rQ?r-`gT1mS2dim+ z6YD2Yh;e{~<yz}s}IRbCP#n6s}>ust9joP4xyzOE~f zpK}L^Y?uc@<{bx|(>D_%M+kgFlGZeB+LB7b ziV@j=qDdTbN#4^bV2B{%oyYCthmx8R7Wb8d@=o;sEcDAu0?IC|e4*ja0`ZmI)h*sS zbY?ntxY!il6U6Pl1G^}HSs^PCIP#JKd3r!Zt%RcQUDlm~+4qJ$~e#5UH!u5tT&{YgjL-W+6xZXp|xy&@gsR_HM7W4 zl0leYJ4-&oEcC^?tr*)JHNRyB!W7vW(FAPF=u>r4S$swZDJz0=F#bff7#ghR9wE0Z zN)Qoy<^|k)@#8K$LKEZnVp?G#|2Ytm*dQW)dnI_2-FxL$%WhxgIYCh(X^0jP^$7yH zZnDWyllhcQuv@I6s{N=Q`LS8E2Qh)`S+|`kicYu#F%m|^<^~WaQWWlM8T#@v{|BMy zaLksnrOo{%)=s3^Ep^g2DR2C|oC#XKZq%bQ@oRfLkNN%=O+I0ySf4OsUn5Q@=FGkEdz(nPRDA41ZhclDodHXKr7Jwnv)b(| zLt;bD?nu6%qz1h+&;$xfjnH9Bk{`XNERYs}Cm|2- z9fYQG?K@Fw{x)X)HV%BW{jeFrim`kZg@v2X-5G8b1i+JTI4c$gJ5;%kCf&RBv`4jq~T1Kyj{rW#vx{5l9&}( z&*}1-hNs*|(S<^$`^v_#=bQvk@XYKoW?beQRb}Czfq#oQHNw|s6}b5&MpNdc)wF>m zFmNMo<#CUMPlB8E{Nw^bBVTJrV^*@+k59KFw$dzr3J@4EQa+Y%v?BYGrBUL70xQ8h zX5Rc6XLLuhK@u8X>v)|v6KI}HDNBj#!GwN{|L}G=_m%<%{7kHCXS>O38h2aA{KHMi zV3_pj{_kTLUq7W=im%gFdy1Q={Z2Z)^uB26e_}J>emi^hw1UVa_rjDZkQvzv$-1A( zVq@Jx%iZESUNJvm8}rlTxz*&LG38Fw+Vg@$aD9{#s9yFzE&WCcLQFt;Gew1Fw`j#=YUEq${~LUI%6Oi z8N{Eb#_W|JAhiZ+OH84MvTGs?;B(Y`KXaA4)`&+*R#WJpLp|^4!Ft1i$neKR5P)>s zx?#co6HL<8US0)JZ2D~%uQQ?NB%uJ{dT2}(l=a3y&u)Q#=C#U~MW^XGz3SPg))5ta zHu!5;frrCs)in+N;`Qbf8;<4oI%q)quT0m)9~?*jLtIs9US!c>cvgR|txfBJX;CrF zRJLHgL@FIXib-$A-GS3bRLD#*u-UY}e4OI3rL|(se?4Q2C-+PHZhymLdPFhn_(^F( zAbX3*sk z=y{53vA!9X3ZLU6Z>TtLlUKWq!~Kh0Y3VU-5HZXsboXqZBfX3%YVb>j-vhh*o~xV2 zLR9+~S_+X$Y0FU99kcpHH)&Lxc#C2Ep|t7Fyl5507Rjn%xnb|!a9gIrfQhf%zLUE! zr{B6l0d!-5%M2SqD~m_Ev~AjnoyIOL;3e%3PX-zN$}c2xR9fZg>Ldc&mDV}eX@!CP>TEDC99#%d@4ZlwYEiRsKAuIW_i? zW=`ZakEvq{5sGEuw*UI?h0E zw(A}u)+VGvNm|yKI1eL*aA1vDVQ`acrA&|aTP~+nxr1PKRbOG)^qf}VP1&@Tj>8-M z0vWYB1bhe`4_Ow?7}gjPeHWplPM?P%`qZ`7(`p&R*75k2cjp2ntuk*)|16ND@d2!4 z(ah1MH|(^b*cNcn(jRJC7rIFCvFNY`>pDPnFzaK?6JYd{$f};B@*#KFpQ!|1$SF7| zqHeag503rs#;3{eF;eT-*CbvAEj{~xGye1YyS&Hg-v5@gzM6VhGcfZ=T zD_cL2rhhRLc5`63>J<&s^o2I4AT+8h{!Z$DN3<(EJ`<{-xM(mWy0cFnqj5|P7MF~uzW zy~|-qj;N`Z_ z(L?x+ezvcqd$5Rjce&e{@AK`~WnVwbsh^pAGH&L#&+{|-bfR)^3I?pVl;gdNHrI}G z6n773X3ttDCk+V)gK5R?RW{+2YI<_1m8&!$WsI-N7!MNq(G_I*`AQ^jtCz6R_^u$u zi8Y^GhsA}36!RMZpapemI`)Zlv3w)1xDw`NUzC784J+6DVr zlH^<05ShX*8;pTt?fGI*_RiehYfFB^S7TBW4b>$v_@mfQ9W@m)hc13J^zgK-`s@s1 zK5@*!zM}8W%;%nW)^CT&@9u<%_@AoBs=pS&l=u44%v`SqCF+!hJPNGiV8F6--Co?+ zV*R2+zD@Q;OhF}JPh5mi39*|${Yb+`U2jK8>V(Efwj zvywWJZx6yvtb$Hpm-KNJD28JvJkc7hG2WF^-3s&(?Q@B?X5Vh6VsCy-alLr}^7vj7 zHnFZ130C4&?>XkzCJVfqa~Rq6EBW0VO_%vNI+_**rbftOx~$~y&n93H@H;OBf7!hM z8Hh`$KRcc->Pn8VVO}>et_8V`D`)h%(m#KhPuqDiuJDNU&;QjWuQKR!`3&TdOjI!fR zV(|2mlkw5Iw*alkId2K}jsbZ11gL&nVsC*}u{d6NTsDvEu&SN~Uq!CW?*5hqh@BB+Ae4Nvd@6lglmMUz7p1i{vD~DSUzg5bQfUKLU*u_tCQ+8Aqk&DucKZQ?jjupm< z-;*6jZH(MoVxKD(XIr6RanajG2381ZE>Q*AI8Fk!1!x;Rrv%0g4`W#Ti#YSPDI3mI zwMS97?lRuSU`N?!B0ULx@!ka;O`av8TBg;LG diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js index 65b5f55..c2cf884 100644 --- a/docs/assets/js/pymm.js +++ b/docs/assets/js/pymm.js @@ -277,6 +277,50 @@ } } + // ------------------------------------------------------------------------- + // Mermaid can't size subgraphs. Diagrams containing "%% pymm:align-groups" + // get their groups stretched to a common width, so they stack cleanly. + // zensical renders diagrams into a closed shadow root, so this has to happen + // in mermaid.render, which we wrap when zensical loads mermaid. + // ------------------------------------------------------------------------- + + function alignGroups(svg) { + const box = document.createElement("div"); + box.innerHTML = svg; + const groups = [...box.querySelectorAll("g.cluster")] + .map((g) => ({ g, rect: g.querySelector(":scope > rect") })) + .filter(({ rect }) => rect); + if (groups.length < 2) return svg; + const num = (el, attr) => parseFloat(el.getAttribute(attr)); + const left = Math.min(...groups.map(({ rect }) => num(rect, "x"))); + const right = Math.max(...groups.map(({ rect }) => num(rect, "x") + num(rect, "width"))); + for (const { g, rect } of groups) { + const shift = (left + right) / 2 - (num(rect, "x") + num(rect, "width") / 2); + rect.setAttribute("x", left); + rect.setAttribute("width", right - left); + const label = g.querySelector(":scope > .cluster-label"); + const m = label?.getAttribute("transform")?.match(/translate\(\s*([-\d.e]+)[\s,]+([-\d.e]+)/); + if (m) label.setAttribute("transform", `translate(${+m[1] + shift}, ${m[2]})`); + } + return box.innerHTML; + } + + let mermaidLib; + Object.defineProperty(window, "mermaid", { + configurable: true, + get: () => mermaidLib, + set(lib) { + mermaidLib = lib; + const render = lib?.render; + if (typeof render !== "function") return; + lib.render = async (id, text, ...rest) => { + const out = await render.call(lib, id, text, ...rest); + if (/%%\s*pymm:align-groups/.test(text)) out.svg = alignGroups(out.svg); + return out; + }; + }, + }); + // ------------------------------------------------------------------------- function init() { diff --git a/docs/getting-started/from-micro-manager.md b/docs/getting-started/from-micro-manager.md index 3aecf5d..229863e 100644 --- a/docs/getting-started/from-micro-manager.md +++ b/docs/getting-started/from-micro-manager.md @@ -23,7 +23,7 @@ fighting over the same hardware). So please close MMStudio before you load your configuration in Python, and vice versa. (The demo devices don't mind, so it's fine to have both open while you're trying things out.) -If you want to keep using MMStudio *and* control it from Python at the same time, +If you want to keep using the classic Java-based MMStudio application *and* control it from Python at the same time, that's what Pycro-Manager is for (see [below](#how-is-this-different-from-pycro-manager)). ## Where things went @@ -107,24 +107,17 @@ directly with the C++ core (via the re-implementing any necessary event-based or GUI-based functionality in pure Python or C. -A major advantage of Pycro-Manager is that you get the entire Java-based -Micro-Manager ecosystem, including the GUI and plugins. A potential disadvantage -(depending on your needs) is that it requires a Java runtime environment and -cross-process communication. +![How pymmcore-plus and Pycro-Manager each reach Micro-Manager's C++ core](../pymmcore-plus/images/components.png){ loading=lazy } -(Pycro-Manager has since added a -[Python backend](https://pycro-manager.readthedocs.io/en/latest/backends.html) for -its headless mode, which also runs the core inside the Python process via -`pymmcore`, along with a Python version of its acquisition engine. It's a -separate project from `pymmcore-plus`, with its own API.) +The advantage of Pycro-Manager is that you get the entire Java-based +Micro-Manager ecosystem, including the well-established GUI and plugins. A +potential disadvantage (depending on your needs) is that it requires a Java +runtime environment and cross-process communication, which adds complexity +and has performance implications. -If you are looking to control a microscope via Python without any need for Java or -interprocess communication, `pymmcore-plus` may help. Note, however, that you will +`pymmcore-plus` was designed to let you control a microscope via Python without +any need for Java or interprocess communication. Note, however, that you will be sacrificing the MMStudio GUI! See [pymmcore-gui](../pymmcore-gui/index.md), [napari-micromanager](../napari-micromanager/index.md) and -[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) for our ongoing efforts to -implement a complete Python-based GUI for Micro-Manager. - -If you've been using Pycro-Manager and like its API, see -[A Pycro-Manager-style API](../pymmcore-plus/examples/pycro-manager-api.md) for an -example of what that could look like on top of `pymmcore-plus`. +[pymmcore-widgets](../pymmcore-widgets/widgets/index.md) for our ongoing efforts +to implement a complete Python-based GUI for Micro-Manager. diff --git a/docs/getting-started/which-package.md b/docs/getting-started/which-package.md index b651d0e..1081a0c 100644 --- a/docs/getting-started/which-package.md +++ b/docs/getting-started/which-package.md @@ -4,31 +4,108 @@ title: Which Package Do I Need? # Which Package Do I Need? -There are five packages here, each building on the ones below it (see the -[stack on the home page](../index.md#how-the-pieces-fit-together)). Most people -only need one of them: +These packages form a stack. Each one builds on the ones below it, and installing +one installs all of the Python packages below it, so you only need to pick one: + +``` mermaid +%% pymm:align-groups (see pymm.js) +graph TB + subgraph py [Python] + gui(pymmcore-gui) + nmm(napari-micromanager) + ndv(ndv) + napari(napari) + widgets(pymmcore-widgets) + plus(pymmcore-plus) + useq(useq-schema) + omew(ome-writers) + pyqt(PyQt / PySide) + pymmcore(pymmcore) + end + subgraph cpp ["C++"] + qt(Qt) + core(MMCore) + adapters("device adapters
(cameras, stages, ...)") + end + gui --> ndv + gui --> widgets + nmm --> widgets + nmm --> napari + widgets --> pyqt + widgets --> plus + plus --> useq + plus --> omew + plus --> pymmcore + pyqt -.-> qt + pymmcore -.-> core + core --> adapters + classDef app fill:#7c3aed,stroke:#7c3aed,color:#fff + classDef wid fill:#db2777,stroke:#db2777,color:#fff + classDef lib fill:#3279b2,stroke:#3279b2,color:#fff + classDef mm fill:#0d9488,stroke:#0d9488,color:#fff + classDef ext fill:#6b7280,stroke:#6b7280,color:#fff + style py rx:10px,ry:10px + style cpp rx:10px,ry:10px + class gui,nmm app + class widgets wid + class plus,useq,omew lib + class pymmcore,core,adapters mm + class ndv,napari,pyqt,qt ext + click gui "../../pymmcore-gui/" + click nmm "../../napari-micromanager/" + click widgets "../../pymmcore-widgets/widgets/" + click plus "../../pymmcore-plus/" + click useq "../../useq-schema/" + click omew "https://pymmcore-plus.github.io/ome-writers/" _blank + click ndv "https://pyapp-kit.github.io/ndv/" _blank + click napari "https://napari.org" _blank + click pymmcore "https://github.com/micro-manager/pymmcore" _blank + click core "https://github.com/micro-manager/mmCoreAndDevices" _blank + click adapters "../install/#installing-the-device-adapters" +``` + +An arrow means "uses": `pymmcore-widgets` uses `pymmcore-plus`, which uses +`pymmcore` to talk to Micro-Manager's C++ core, which in turn talks to your hardware +through the device adapters. + +(If you've used Micro-Manager before, +[Coming from Micro-Manager](from-micro-manager.md) maps what you know onto these +packages.) + +## Applications + +If you mainly want to work with your microscope through a GUI, start with one of +the two apps. They're alternatives to each other, both built on the libraries +below. Both can be scripted from Python while they're running, and pymmcore-gui +can be extended with your own widgets, so they're worth a look even if you write +code: + +| App | What it is | Start here | +| --- | --- | --- | +| pymmcore-gui | A desktop application (our replacement for Micro-Manager's Java GUI, still early) | [pymmcore-gui](../pymmcore-gui/index.md) | +| napari-micromanager | Microscope control from inside [napari](https://napari.org) | [napari-micromanager](../napari-micromanager/index.md) | + +## Libraries + +If you want to write your own scripts or applications, pick the highest library you +need (it includes the ones below it): | If you want to… | Use | Start here | | --- | --- | --- | -| Control a microscope with a point-and-click app | pymmcore-gui | [pymmcore-gui](../pymmcore-gui/index.md) | -| Control a microscope from inside [napari](https://napari.org) | napari-micromanager | [napari-micromanager](../napari-micromanager/index.md) | -| Script your microscope from Python or Jupyter | pymmcore-plus | [First steps](first-steps.md) | -| Run multi-dimensional acquisitions from code | pymmcore-plus (includes useq-schema) | [Your first acquisition](first-acquisition.md) | -| Describe experiments independently of any hardware | useq-schema | [Describing experiments](../useq-schema/index.md) | | Build your own Qt GUI for a microscope | pymmcore-widgets | [Your first GUI](first-gui.md) | +| Script your microscope, or run multi-dimensional acquisitions | pymmcore-plus | [First steps](first-steps.md), then [Your first acquisition](first-acquisition.md) | +| Describe experiments without any hardware | useq-schema | [Describing experiments](../useq-schema/index.md) | (A *multi-dimensional acquisition*, or MDA, is a series of images across time points, stage positions, channels, z-planes, etc... It's what Micro-Manager's Multi-D Acquisition window does.) -Installing a package also installs the ones it builds on (`pip install -pymmcore-widgets` gets you `pymmcore-plus`, `useq-schema` and `pymmcore` too). The -Micro-Manager device adapters (the drivers for your hardware) are *not* Python -packages, though, and always need to be installed separately. -[Installation](install.md) covers both, so whichever package you pick, start there. +## Installing -If you've used Micro-Manager before, you may also want to read -[Coming from Micro-Manager](from-micro-manager.md). +The Micro-Manager device adapters (the drivers for your hardware) are *not* +Python packages, so none of these install them for you. +[Installation](install.md) covers the packages and the adapters, so whichever one +you pick, start there. ## What each package does @@ -46,6 +123,11 @@ If you've used Micro-Manager before, you may also want to read to stay agnostic to the acquisition engine (though it was designed around what Micro-Manager can do). +[ome-writers](https://pymmcore-plus.github.io/ome-writers/) +: Writes OME-Zarr and OME-TIFF files. pymmcore-plus uses it to + [save acquisitions](../pymmcore-plus/guides/saving_data.md), but it doesn't + depend on Micro-Manager and can be used on its own. + [pymmcore-widgets](../pymmcore-widgets/widgets/index.md) : Qt widgets for a `CMMCorePlus`: property browsers, device controls, configuration editors, MDA editors, image previews, etc... diff --git a/docs/index.md b/docs/index.md index 258df5f..7ddb7cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,14 +11,14 @@ hide:
-# pymmcore-plus + -These docs cover `pymmcore-plus` and the packages built around it, which let you -control a microscope from Python by talking directly to -[Micro-Manager](https://micro-manager.org)'s C++ core and device adapters (with no -Java process involved). You can use them to script your hardware and run -multi-dimensional acquisitions, or to build your own GUI (or just use one of -ours). +# Control microscopes with Python + +A set of Python packages for running a microscope through +[Micro-Manager](https://micro-manager.org)'s C++ core and device adapters, with no +Java process involved. Script your hardware, run multi-dimensional acquisitions, +build your own GUI, or just use one of ours. [Get started](getting-started/which-package.md){ .md-button .md-button--primary } [User guide](guide/index.md){ .md-button } @@ -47,17 +47,51 @@ core.mda.run(sequence, output="data.ome.zarr") ## Where to start -If you're new here, [Getting Started](getting-started/which-package.md) walks you through -installing things, controlling Micro-Manager's demo devices from Python, running a -first acquisition, and putting together a small GUI (no microscope required). If -you already know Micro-Manager, have a look at -[Coming from Micro-Manager](getting-started/from-micro-manager.md), which maps what -you know onto these packages. +
+ +- :lucide-terminal:{ .lg .middle } **Script a microscope** + + --- + + Load a configuration, move stages, snap images, and connect callbacks to + changes in the core, from a script or a notebook. + + [:octicons-arrow-right-24: First steps](getting-started/first-steps.md) + +- :lucide-layers:{ .lg .middle } **Run acquisitions** + + --- + + Describe time-lapses, z-stacks, channels and positions with `useq`, run them + with an acquisition engine you can customize, and save to OME-Zarr or + OME-TIFF. + + [:octicons-arrow-right-24: Your first acquisition](getting-started/first-acquisition.md) + +- :lucide-app-window:{ .lg .middle } **Build a GUI** + + --- + + Combine Qt widgets (property browsers, stage controls, MDA editors, image + previews, etc...) into your own application. -If you'd rather not write any code, [pymmcore-gui](pymmcore-gui/index.md) is our -(work in progress) desktop application, and -[napari-micromanager](napari-micromanager/index.md) controls a microscope from -inside [napari](https://napari.org). + [:octicons-arrow-right-24: Your first GUI](getting-started/first-gui.md) + +- :lucide-microscope:{ .lg .middle } **Just use an app** + + --- + + Point-and-click microscope control with `pymmcore-gui`, or inside napari + with `napari-micromanager`. + + [:octicons-arrow-right-24: Applications](pymmcore-gui/index.md) + +
+ +[Getting Started](getting-started/which-package.md) goes through these in order, +using Micro-Manager's demo devices (no microscope required). If you already know +Micro-Manager, [Coming from Micro-Manager](getting-started/from-micro-manager.md) +maps what you know onto these packages. ## How the pieces fit together @@ -80,8 +114,8 @@ as you want (`pymmcore-plus` on its own is enough to script a microscope).
Control & acquisition
-
[**pymmcore-plus**](pymmcore-plus/index.md) · [**useq-schema**](useq-schema/index.md)
-
The core, extended with events and an acquisition engine, and a schema for describing experiments.
+
[**pymmcore-plus**](pymmcore-plus/index.md) · [**useq-schema**](useq-schema/index.md) · [**ome-writers**](https://pymmcore-plus.github.io/ome-writers/)
+
The core, extended with events and an acquisition engine, a schema for describing experiments, and writers for OME-Zarr and OME-TIFF.
@@ -97,12 +131,17 @@ as you want (`pymmcore-plus` on its own is enough to script a microscope).
-## Getting help +## Community + +
+ +- :fontawesome-brands-discourse: **Ask a question** on the + [image.sc forum](https://forum.image.sc/tag/pymmcore-plus) -If you have usage questions (*"How do I...?"*), please ask on the -[image.sc forum](https://forum.image.sc/tag/pymmcore-plus) with the `pymmcore-plus` -tag, or come chat with us on -[Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D). -Bug reports and feature requests are always welcome on -[GitHub](https://github.com/pymmcore-plus) (each package has its own repository; -see [Contributing](contributing/index.md) if you're not sure which one). +- :simple-zulip: **Chat with developers** on + [Zulip](https://imagesc.zulipchat.com/#narrow/channel/442785-pymmcore.5B-plus.5D) + +- :fontawesome-brands-github: **Report a bug or request a feature** on + [GitHub](https://github.com/pymmcore-plus) + +
diff --git a/overrides/main.html b/overrides/main.html deleted file mode 100644 index 8bc3824..0000000 --- a/overrides/main.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "base.html" %} - -{% block announce %} - The pymmcore-plus docs are now one site. - Report a problem -{% endblock %} diff --git a/tools/compose.py b/tools/compose.py index f1234ea..9b83047 100644 --- a/tools/compose.py +++ b/tools/compose.py @@ -364,7 +364,9 @@ def build_config(members: list[Member]) -> dict[str, Any]: project.setdefault("extra", {})["members"] = { m.name: {"title": m.title, "repo": m.repo, "pypi": m.pypi} for m in members } - project["watch"] = [p for p in handler["paths"]] + ["docs", "overrides"] + # only things that need a full reload: src for mkdocstrings, templates. Pages + # reach build/docs via cmd_serve's copy, and zensical rebuilds those itself. + project["watch"] = [p for p in handler["paths"]] + ["overrides"] return project diff --git a/zensical.base.toml b/zensical.base.toml index b5b261b..8d609d5 100644 --- a/zensical.base.toml +++ b/zensical.base.toml @@ -20,13 +20,10 @@ extra_javascript = ["assets/js/pymm.js"] [project.theme] custom_dir = "overrides" variant = "modern" -icon.logo = "lucide/microscope" -favicon = "assets/img/favicon.ico" +logo = "assets/img/logo.svg" +favicon = "assets/img/favicon.png" features = [ - "announce.dismiss", "content.action.copy", - "content.action.edit", - "content.action.view", "content.code.annotate", "content.code.copy", "content.code.select", From 0dd8abd67121097c3b85cc2d6352689031a2c872 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 Oct 2026 10:06:45 +0100 Subject: [PATCH 14/15] docs: drop the logo from the home page hero The header already shows it, right above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/assets/css/pymm.css | 6 ------ docs/index.md | 2 -- 2 files changed, 8 deletions(-) diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index 1b0af4f..1f1f4e6 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -57,12 +57,6 @@ letter-spacing: -0.02em; } -.pymm-hero__logo { - display: block; - width: 4.5rem; - margin-bottom: 1.2rem; -} - .pymm-hero__text p { font-size: 0.95rem; } diff --git a/docs/index.md b/docs/index.md index 7ddb7cc..73456f7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,8 +11,6 @@ hide:
- - # Control microscopes with Python A set of Python packages for running a microscope through From 853bee0757924f0548b65572a1cb6cc82ea27c8a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 27 Oct 2026 19:42:34 +0100 Subject: [PATCH 15/15] docs: make sidebar group headings plain labels with a rule along their pages Group headings in the active tab's sidebar are never links now (a nav-item.html override drops the section index link at level 2), so the two groups whose first page was an index get explicit titles instead. Headings are small uppercase labels, each group's pages hang off a thin left rule, and the current page is an accent segment of that rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019cDFrDDGJkJSy4zsEUj3Fp --- docs/.nav.yml | 4 +- docs/assets/css/pymm.css | 42 ++++++++ overrides/partials/nav-item.html | 178 +++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 overrides/partials/nav-item.html diff --git a/docs/.nav.yml b/docs/.nav.yml index 7d3e4ea..754c39a 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -20,7 +20,7 @@ nav: - pymmcore-plus/guides/events.md - pymmcore-plus/guides/ipython_completion.md - Describing Experiments: - - useq-schema/index.md + - The useq-schema Package: useq-schema/index.md - useq-schema/guides/sequences.md - Running Acquisitions: - pymmcore-plus/guides/mda_engine.md @@ -28,7 +28,7 @@ nav: - pymmcore-plus/guides/event_driven_acquisition.md - pymmcore-plus/guides/custom_engine.md - Building GUIs: - - pymmcore-widgets/widgets/index.md + - Widget Gallery: pymmcore-widgets/widgets/index.md - Building a Custom GUI: pymmcore-widgets/getting_started.md - Qt Integration: pymmcore-plus/examples/integration-with-qt.md - Applications: diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css index 1f1f4e6..3ad8d03 100644 --- a/docs/assets/css/pymm.css +++ b/docs/assets/css/pymm.css @@ -37,6 +37,48 @@ --pymm-border: #30363d; } +/* --------------------------------------------------------------------------- + Sidebar: group headings are small labels (never links; see + overrides/partials/nav-item.html), and a rule runs along each group's pages + ------------------------------------------------------------------------ */ + +/* a group in the active tab's sidebar */ +@media screen and (min-width: 76.25em) { + .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section { + margin-top: 1.1rem; + } + + .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section > .md-nav__link { + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); + } + + [dir="ltr"] .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section .md-nav__list { + margin-left: 0.7rem; + border-left: 0.05rem solid var(--md-default-fg-color--lighter); + } + + [dir="ltr"] .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section .md-nav__list > .md-nav__item > .md-nav__link { + margin-left: -0.05rem; + padding-left: 0.6rem; + border-left: 0.1rem solid transparent; + border-radius: 0 0.4rem 0.4rem 0; + } + + /* the current page is a segment of the rule, not a pill */ + .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section .md-nav__link--active { + background: none; + } + + .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section .md-nav__list > .md-nav__item > .md-nav__link--active, + .md-nav--primary [data-md-level="1"] > .md-nav__list > .md-nav__item--section .md-nav__list > .md-nav__item > .md-nav__link:has(> .md-nav__link--active) { + border-left-color: var(--md-accent-fg-color); + } +} + /* --------------------------------------------------------------------------- Landing page ------------------------------------------------------------------------ */ diff --git a/overrides/partials/nav-item.html b/overrides/partials/nav-item.html new file mode 100644 index 0000000..66db397 --- /dev/null +++ b/overrides/partials/nav-item.html @@ -0,0 +1,178 @@ +{#- + zensical's partials/nav-item.html (0.0.73) with one change, marked "pymm": + the group headings in the sidebar are labels, never pages. With + navigation.indexes, a group's index.md would otherwise become its heading, + so some headings were links and others weren't. The index page is listed + as the group's first item instead. Re-copy this file when upgrading zensical. +-#} +{% macro render_status(nav_item, type) %} + {% set class = "md-status md-status--" ~ type %} + {% if config.extra.status and config.extra.status[type] %} + + + {% else %} + + {% endif %} +{% endmacro %} +{% macro render_title(nav_item, ref) %} + {% set ref = ref or nav_item %} + {{ ref.title or nav_item.title }} +{% endmacro %} +{% macro render_content(nav_item, ref) %} + {% set ref = ref or nav_item %} + {% if nav_item.meta and nav_item.meta.icon %} + {% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %} + {% endif %} + + {{ render_title(nav_item, ref) }} + {% if nav_item.meta and nav_item.meta.subtitle %} +
+ {{ nav_item.meta.subtitle }} + {% endif %} +
+ {% if nav_item.meta and nav_item.encrypted %} + {{ render_status(nav_item, "encrypted") }} + {% endif %} + {% if nav_item.meta and nav_item.meta.status %} + {{ render_status(nav_item, nav_item.meta.status) }} + {% endif %} +{% endmacro %} +{% macro render_pruned(nav_item, ref, content) %} + {% set ref = ref or nav_item %} + {% set content = content or ref %} + {% set first = content if content.is_index else nav_item.children | first %} + {% if first and first.children %} + {{ render_pruned(first, ref, content) }} + {% else %} + + {{ render_content(content, ref) }} + {% if not content.is_index or nav_item.children | length > 1 %} + + {% endif %} + + {% endif %} +{% endmacro %} +{% macro render(nav_item, path, level, parent) %} + {% set class = "md-nav__item" %} + {% if nav_item.active %} + {% set class = class ~ " md-nav__item--active" %} + {% endif %} + {% if nav_item.pages %} + {% if page in nav_item.pages %} + {% set nav_item = page %} + {% endif %} + {% endif %} + {% if nav_item.children %} + {% set _ = namespace(index = none) %} + {% if "navigation.indexes" in features %} + {% for item in nav_item.children %} + {% if item.is_index and _.index is none %} + {% set _.index = item %} + {% endif %} + {% endfor %} + {% endif %} + {% set index = _.index %} + {% if "navigation.tabs" in features %} + {% if level == 1 and nav_item.active %} + {% set class = class ~ " md-nav__item--section" %} + {% set is_section = true %} + {% endif %} + {% if "navigation.sections" in features %} + {% if level == 2 and parent.active %} + {% set class = class ~ " md-nav__item--section" %} + {% set is_section = true %} + {% endif %} + {% endif %} + {% elif "navigation.sections" in features %} + {% if level == 1 %} + {% set class = class ~ " md-nav__item--section" %} + {% set is_section = true %} + {% endif %} + {% endif %} + {#- pymm: sidebar group headings are never links -#} + {% if is_section and level == 2 %} + {% set index = none %} + {% endif %} + {% if "navigation.prune" in features %} + {% if not is_section and not nav_item.active %} + {% set class = class ~ " md-nav__item--pruned" %} + {% set is_pruned = true %} + {% endif %} + {% endif %} +
  • + {% if not is_pruned %} + {% set checked = "checked" if nav_item.active %} + {% if "navigation.expand" in features and not checked %} + {% set indeterminate = "md-toggle--indeterminate" %} + {% endif %} + + {% if not index %} + {% set tabindex = "0" if not is_section %} + + {% else %} + {% set class = "md-nav__link--active" if index.active %} + + {% endif %} + {% if not index or nav_item.children | length > 1 %} + + {% endif %} + {% else %} + {{ render_pruned(nav_item, none, index) }} + {% endif %} +
  • + {% elif nav_item.active %} +
  • + {% set toc = page.toc %} + {% if config.theme.variant == "classic" %} + + {% endif %} + {% set first = toc | first %} + {% if first and first.level == 1 %} + {% set toc = first.children %} + {% endif %} + {% if toc %} + + {% endif %} + + {{ render_content(nav_item) }} + + {% if toc %} + {% include "partials/toc.html" %} + {% endif %} +
  • + {% else %} +
  • + + {{ render_content(nav_item) }} + +
  • + {% endif %} +{% endmacro %}