diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9fb5ba0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,114 @@ +# 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" + + # icons that some widgets fetch from the Iconify API (via pyconify), which + # rate-limits CI runners + - uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/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) + 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 + # 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: + # 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" + 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..651508d --- /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 (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 +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..9dfc487 --- /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 = "" # installed from GitHub for now; the PyPI release is an old pre-release + +[[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..754c39a --- /dev/null +++ b/docs/.nav.yml @@ -0,0 +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 (for the +# applications, pymmcore-gui and napari-micromanager, its pages). +nav: + - Home: index.md + - Getting Started: + - getting-started/which-package.md + - getting-started/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: + - 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/events.md + - pymmcore-plus/guides/ipython_completion.md + - Describing Experiments: + - The useq-schema Package: useq-schema/index.md + - useq-schema/guides/sequences.md + - Running Acquisitions: + - pymmcore-plus/guides/mda_engine.md + - pymmcore-plus/guides/saving_data.md + - pymmcore-plus/guides/event_driven_acquisition.md + - pymmcore-plus/guides/custom_engine.md + - Building GUIs: + - 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: + - pymmcore-gui + - napari-micromanager + - Advanced Topics: + - pymmcore-plus/guides/unicore.md + - useq-schema v2 (Preview): useq-schema/v2-migration.md + - pymmcore-plus/examples/pycro-manager-api.md + - Troubleshooting: + - 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 + - Contributing: + - contributing/index.md + - contributing/docs.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 new file mode 100644 index 0000000..09b4641 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,62 @@ +--- +title: API Reference +--- + +# API Reference + +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. + +
+ +- **[pymmcore-plus](../pymmcore-plus/index.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 the + plans for each axis (time, z, positions, grids, autofocus). + +- **[pymmcore-widgets](../pymmcore-widgets/api/pymmcore_widgets/index.md)** + + --- + + 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)** + + --- + + 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 + `https://pymmcore-plus.github.io/objects.inv` for everything). diff --git a/docs/assets/css/pymm.css b/docs/assets/css/pymm.css new file mode 100644 index 0000000..3ad8d03 --- /dev/null +++ b/docs/assets/css/pymm.css @@ -0,0 +1,587 @@ +/* Styles for the unified pymmcore-plus docs. */ + +/* --------------------------------------------------------------------------- + Brand colors (palette primary/accent = "custom") + ------------------------------------------------------------------------ */ + +:root, +[data-md-color-scheme="default"] { + /* 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: #a16207; + --md-accent-fg-color--transparent: #ffcb3533; + --pymm-added: #b45309; + --pymm-overridden: #3279b2; + --pymm-inherited: #6b7280; + --pymm-surface: #f6f8fa; + --pymm-border: #e1e4e8; +} + +[data-md-color-scheme="slate"] { + /* 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: #ffcb35; + --md-accent-fg-color--transparent: #ffcb351a; + --pymm-added: #f59e0b; + --pymm-overridden: #6aa6d8; + --pymm-inherited: #9ca3af; + --pymm-surface: #161b22; + --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 + ------------------------------------------------------------------------ */ + +.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 { + /* 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; +} + +/* 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; + 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 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 { + 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); +} + +.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--light); + 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 (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: 4.8rem; + 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--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 */ +.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; +} + +/* 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 + ------------------------------------------------------------------------ */ + +/* 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; + flex-wrap: wrap; + align-items: center; + margin: 0; + 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::-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 { + display: none; +} +.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__item + .pymm-backlinks__item::before { + content: "·"; + margin: 0 0.6em; + opacity: 0.6; +} +.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; + 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; +} + +/* --------------------------------------------------------------------------- + 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/img/favicon.png b/docs/assets/img/favicon.png new file mode 100644 index 0000000..c5f19f6 Binary files /dev/null and b/docs/assets/img/favicon.png differ diff --git a/docs/assets/img/logo.svg b/docs/assets/img/logo.svg new file mode 100644 index 0000000..fdc1447 --- /dev/null +++ b/docs/assets/img/logo.svg @@ -0,0 +1 @@ + diff --git a/docs/assets/js/pymm.js b/docs/assets/js/pymm.js new file mode 100644 index 0000000..c2cf884 --- /dev/null +++ b/docs/assets/js/pymm.js @@ -0,0 +1,338 @@ +// 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: "added" }, + 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); + + // 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; + 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-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" }, + ]; + + // 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 = 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(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"}]"`); + else if (opts.io && !opts.gui) pkgs.push(`"pymmcore-plus[io]"`); + 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 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"); + 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(); + } + + function checkbox(o) { + return ``; + } + + // ------------------------------------------------------------------------- + // Backlinks (mkdocstrings "Referenced by", "Used by", ...): collapse each + // 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) => { + 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 }; + }); + // 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"; + const summary = document.createElement("summary"); + summary.innerHTML = + CHEVRON + + parts + .map((p) => `${p.label} ${p.n}`) + .join(""); + block.replaceWith(details); + details.append(summary, block); + } + + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // 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"); + } + } + + // ------------------------------------------------------------------------- + // 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() { + 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") { + 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..51e97ec --- /dev/null +++ b/docs/contributing/docs.md @@ -0,0 +1,100 @@ +--- +title: How These Docs Work +--- + +# How These Docs Work + +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*). + +```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 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/) + 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* (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 + +```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..d6cb346 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,54 @@ +--- +title: Contributing +--- + +# Contributing + +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 + +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? 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 + +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 +cd +uv sync +uv run prek install # or: uv run pre-commit install +uv run pytest +``` + +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 + +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 new file mode 100644 index 0000000..34a6862 --- /dev/null +++ b/docs/getting-started/first-acquisition.md @@ -0,0 +1,146 @@ +--- +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, 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). + +We'll need a core with a configuration loaded: + +```python +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() +``` + +## Describing the experiment + +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=[(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 +``` + +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 +@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", overwrite=True) # (2)! +``` + +1. Connect to [events][pymmcore_plus.mda.events.PMDASignaler] to process data + 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`. + +... prints a line for each of the 60 images: + +``` +{'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) +``` + +## 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 +import zarr + +data = zarr.open_array("experiment.ome.zarr/0/0", mode="r") +data.shape # (3, 2, 5, 512, 512) +``` + +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`). + +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) +``` + +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) 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 new file mode 100644 index 0000000..60b2151 --- /dev/null +++ b/docs/getting-started/first-gui.md @@ -0,0 +1,109 @@ +--- +title: Your First GUI +--- + +# Your First GUI + +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`. + +## 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 + mmgui run --demo-config + ``` + + 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 + napari-micromanager + ``` + + Then pick a configuration file at the top left of the window (or start it + with `napari-micromanager -c path/to/config.cfg`). + +## Building your own + +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.) + +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: + +```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. 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 +([`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%'} + +## Next steps + +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 new file mode 100644 index 0000000..b4a9ca7 --- /dev/null +++ b/docs/getting-started/first-steps.md @@ -0,0 +1,161 @@ +--- +title: First Steps +--- + +# First Steps + +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. + +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. + +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. + +!!! 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 + +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 (see + [Using your own microscope](../guide/own-microscope.md)). + +## Snapping an image + +```python +core.setExposure(25) # milliseconds +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. + +`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 + +Each device has a number of *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. + +## Configuration groups and presets + +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') +core.setConfig("Channel", "FITC") +core.getCurrentConfig("Channel") # 'FITC' +``` + +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() # (1)! +core.getXYPosition() # [100.005, 199.995] +``` + +1. Stage movements may return before the hardware has finished moving. + `waitForSystem` blocks until all devices are done. + +## 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 # (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" +``` + +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 + +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..229863e --- /dev/null +++ b/docs/getting-started/from-micro-manager.md @@ -0,0 +1,123 @@ +--- +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 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 + +| 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. + +![How pymmcore-plus and Pycro-Manager each reach Micro-Manager's C++ core](../pymmcore-plus/images/components.png){ loading=lazy } + +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. + +`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. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md new file mode 100644 index 0000000..94adaa0 --- /dev/null +++ b/docs/getting-started/install.md @@ -0,0 +1,133 @@ +--- +title: Installation +--- + +# Installation + +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. + +You'll need Python 3.11 or newer. + +!!! question "Where do I type these commands?" + + 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: + +
+ +=== "pip" + + ```sh + pip install "pymmcore-plus[cli]" + ``` + +=== "uv" + + ```sh + uv add "pymmcore-plus[cli]" + ``` + +
+ +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 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 (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]`. + + 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.) + +## Installing the device adapters + +The `mmcore` command downloads the latest compatible Micro-Manager device +adapters for your platform: + +```sh +mmcore install +``` + +(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). + +!!! 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 +``` + +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 + +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..1081a0c --- /dev/null +++ b/docs/getting-started/which-package.md @@ -0,0 +1,143 @@ +--- +title: Which Package Do I Need? +--- + +# Which Package Do I Need? + +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 | +| --- | --- | --- | +| 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 + +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 + +[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). + +[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... + +[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 new file mode 100644 index 0000000..2cae061 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,79 @@ +--- +title: User Guide +--- + +# User Guide + +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. + +
+ +- :lucide-sliders-horizontal:{ .lg .middle } **Controlling hardware** + + --- + + [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** + + --- + + 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](../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** + + --- + + 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](../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** + + --- + + [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 new file mode 100644 index 0000000..73456f7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,145 @@ +--- +title: Home +description: Control microscopes with Python, using Micro-Manager's device adapters. +hide: + - navigation + - toc + - path +--- + +
+ +
+ +# 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 } + +
+ +
+ +```python +import useq +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus() +core.loadSystemConfiguration() # demo devices + +sequence = useq.MDASequence( + channels=["DAPI", "FITC"], + time_plan={"interval": 1, "loops": 5}, +) +core.mda.run(sequence, output="data.ome.zarr") +``` + +
+ +
+ +## Where to start + +
+ +- :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. + + [: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 + +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)
+
Desktop applications for running a microscope.
+
+ +
+
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) · [**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.
+
+ +
+
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, etc...
+
+ +
+
Your microscope
+
:lucide-microscope:   Micro-Manager's demo devices, or your own hardware
+
+ +
+ +## 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/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..1ed811c --- /dev/null +++ b/overrides/partials/content.html @@ -0,0 +1,27 @@ +{#- + 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/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 %} 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..1e158a2 --- /dev/null +++ b/redirects.toml @@ -0,0 +1,52 @@ +# 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/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" +"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" +"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" +"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..fd968fc --- /dev/null +++ b/tools/api.py @@ -0,0 +1,450 @@ +"""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 + 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: + 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) + children: list[ModulePage] = field(default_factory=list) # submodule pages + + @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(): + 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 + 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 + 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] = {} + 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 +# --------------------------------------------------------------------------- + + +# 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) + 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: + lines = [ + "---", + f"title: {json.dumps(page.path)}", + f"package: {json.dumps(page.config.member)}", + "generated: true", + ] + # "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(".", "-") + 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 + ] + 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 can also be imported from this module, but each is documented " + "under its shortest import path (linked below).", + "", + "| 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] = [] + # 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": []} + ) + 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 (not + # the package page itself, when a hand-written one replaces it) + for c in page.claimed: + 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) + 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]} + ) + # 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( + 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..9b83047 --- /dev/null +++ b/tools/compose.py @@ -0,0 +1,704 @@ +"""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] + cfg.edit_base = f"https://github.com/{m.repo}/edit/{m.branch}/" + 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 + } + # 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 + + +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") + + +# 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 +# --------------------------------------------------------------------------- + + +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` and `pymm_griffe` (our Markdown and griffe + # extensions) are 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) + clean_llms_txt() + 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: + 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 + + +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_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 new file mode 100644 index 0000000..3be0105 --- /dev/null +++ b/tools/pymm_mdx.py @@ -0,0 +1,124 @@ +"""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, 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 + +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}" +) +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 +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) + 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:") + 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..8d609d5 --- /dev/null +++ b/zensical.base.toml @@ -0,0 +1,230 @@ +# 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" +logo = "assets/img/logo.svg" +favicon = "assets/img/favicon.png" +features = [ + "content.action.copy", + "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 +# 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 +auto_themed = true + +[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" + +# keep in sync with docs/.nav.yml: pages in no section get no "copy as Markdown" +[project.plugins.llmstxt.sections] +"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 + +[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 +# 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 = []